mirror of
https://github.com/expo/entity.git
synced 2026-11-15 03:48:08 -10:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b61341057b | ||
|
|
bc378b6c79 | ||
|
|
fefa4100fc | ||
|
|
85d35752fa | ||
|
|
09d48a617c | ||
|
|
0adcf7238f | ||
|
|
41bd0722c9 | ||
|
|
03a1fce067 | ||
|
|
a2c6dd7488 | ||
|
|
e1b0ee5281 | ||
|
|
5d39fa928f | ||
|
|
f1d9847210 | ||
|
|
99657196f8 | ||
|
|
3727191767 | ||
|
|
efa131ef29 | ||
|
|
c4df889d50 | ||
|
|
7cf54d83c5 | ||
|
|
1405a9126e |
No files matched your search
@@ -3,6 +3,80 @@
|
|||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||||
|
|
||||||
|
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add local memory cache package ([#158](https://github.com/expo/entity/issues/158)) ([85d3575](https://github.com/expo/entity/commit/85d35752fa0919a62efb776a762a6829e83ca7af))
|
||||||
|
|
||||||
|
### Chores
|
||||||
|
|
||||||
|
* Refactor cachers to use an abstract class and interface ([#157](https://github.com/expo/entity/pull/157))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# [0.23.0](https://github.com/expo/entity/compare/v0.22.0...v0.23.0) (2022-02-09)
|
||||||
|
|
||||||
|
### Breaking changes
|
||||||
|
|
||||||
|
* Move data transforming responsibilities to the CacheAdapter instead of the ReadThroughEntityCache. Affects any classes extending the EntityCacheAdapter and relying on the transformation behavior of the ReadThroughEntityCache. ([#153](https://github.com/expo/entity/pull/153))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* Upgrade knex to 1.x in `@expo/entity-database-adapter-knex` ([#150](https://github.com/expo/entity/issues/150)) ([e1b0ee5](https://github.com/expo/entity/commit/e1b0ee528175646239d88aa71ffce73983eeb52f))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# [0.21.0](https://github.com/expo/entity/compare/v0.20.0...v0.21.0) (2022-01-03)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add pre-commit callbacks on EntityQueryContext ([#147](https://github.com/expo/entity/issues/147)) ([f1d9847](https://github.com/expo/entity/commit/f1d9847210f5775de964509e44ce493e44482b21))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# [0.20.0](https://github.com/expo/entity/compare/v0.19.0...v0.20.0) (2021-12-29)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* separate post-commit invalidation callbacks from trigger and user callbacks ([#144](https://github.com/expo/entity/issues/144)) ([efa131e](https://github.com/expo/entity/commit/efa131ef29532accf0e51b6d867ffe15a27b4c90))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add cascading deletion info ([#145](https://github.com/expo/entity/issues/145)) ([3727191](https://github.com/expo/entity/commit/372719176742d3ead6b7cbca29cb66b09b3fe09c))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# [0.19.0](https://github.com/expo/entity/compare/v0.18.0...v0.19.0) (2021-10-28)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add authorization metrics ([#142](https://github.com/expo/entity/issues/142)) ([7cf54d8](https://github.com/expo/entity/commit/7cf54d83c52e104159a3e2e675cdda2d29afe204))
|
||||||
|
* add IP support ([#138](https://github.com/expo/entity/issues/138)) ([1405a91](https://github.com/expo/entity/commit/1405a9126ee3a56c3e2a38bf3b697283744f5b5d))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# [0.18.0](https://github.com/expo/entity/compare/v0.17.0...v0.18.0) (2021-10-14)
|
# [0.18.0](https://github.com/expo/entity/compare/v0.17.0...v0.18.0) (2021-10-14)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -4,5 +4,5 @@
|
|||||||
],
|
],
|
||||||
"npmClient": "yarn",
|
"npmClient": "yarn",
|
||||||
"useWorkspaces": true,
|
"useWorkspaces": true,
|
||||||
"version": "0.18.0"
|
"version": "0.24.0"
|
||||||
}
|
}
|
||||||
+5
-1
@@ -18,9 +18,11 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@expo/entity": "file:packages/entity",
|
"@expo/entity": "file:packages/entity",
|
||||||
"@expo/results": "^1.0.0",
|
"@expo/results": "^1.0.0",
|
||||||
|
"@types/lru-cache": "^5.1.1",
|
||||||
"@types/invariant": "^2.2.33",
|
"@types/invariant": "^2.2.33",
|
||||||
"@types/ioredis": "^4.26.4",
|
"@types/ioredis": "^4.26.4",
|
||||||
"@types/jest": "^26.0.10",
|
"@types/jest": "^26.0.10",
|
||||||
|
"@types/jsbn": "^1.2.29",
|
||||||
"@types/node": "^14.6.0",
|
"@types/node": "^14.6.0",
|
||||||
"@types/uuid": "^8.3.0",
|
"@types/uuid": "^8.3.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^4.14.0",
|
"@typescript-eslint/eslint-plugin": "^4.14.0",
|
||||||
@@ -42,8 +44,10 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/entity": "file:packages/entity",
|
"@expo/entity": "file:packages/entity",
|
||||||
|
"@expo/entity-cache-adapter-local-memory": "file:packages/entity-cache-adapter-local-memory",
|
||||||
"@expo/entity-cache-adapter-redis": "file:packages/entity-cache-adapter-redis",
|
"@expo/entity-cache-adapter-redis": "file:packages/entity-cache-adapter-redis",
|
||||||
"@expo/entity-database-adapter-knex": "file:packages/entity-database-adapter-knex",
|
"@expo/entity-database-adapter-knex": "file:packages/entity-database-adapter-knex",
|
||||||
"@expo/entity-secondary-cache-redis": "file:packages/entity-secondary-cache-redis"
|
"@expo/entity-secondary-cache-redis": "file:packages/entity-secondary-cache-redis",
|
||||||
|
"@expo/entity-ip-address-field": "file:packages/entity-ip-address-field"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Change Log
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||||
|
|
||||||
|
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add local memory cache package ([#158](https://github.com/expo/entity/issues/158)) ([85d3575](https://github.com/expo/entity/commit/85d35752fa0919a62efb776a762a6829e83ca7af))
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# `@expo/entity-cache-adapter-local-memory`
|
||||||
|
|
||||||
|
Cross-request [LRU](https://github.com/isaacs/node-lru-cache) cache adapter for `@expo/entity`. Use
|
||||||
|
this cache with caution - it is nonstandard. The cache is shared between requests in the node process.
|
||||||
|
|
||||||
|
## Why NOT use this cache
|
||||||
|
|
||||||
|
Because this is an in-memory cache, cross-box invalidation is not possible. Do not use this cache
|
||||||
|
if you have the following use cases:
|
||||||
|
|
||||||
|
- The objects stored have high mutability
|
||||||
|
- Reading a stale object from the cache is not acceptable in your application
|
||||||
|
- Cross-box invalidation is not possible
|
||||||
|
|
||||||
|
## Typical use cases
|
||||||
|
|
||||||
|
If your application sees many requests fetching the same objects, you can save a trip to your cache
|
||||||
|
cluster and backing datastore by using this in-memory cache. Here are some good use cases:
|
||||||
|
|
||||||
|
- The objects stored are mostly immutable
|
||||||
|
- You have a low TTL setting in your cache
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
During `EntityCompanionProvider` instantiation:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const createDefaultEntityCompanionProvider = (
|
||||||
|
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter()
|
||||||
|
): EntityCompanionProvider => {
|
||||||
|
return new EntityCompanionProvider(
|
||||||
|
metricsAdapter,
|
||||||
|
{
|
||||||
|
...
|
||||||
|
},
|
||||||
|
{
|
||||||
|
['local-memory']: {
|
||||||
|
cacheAdapterProvider: new LocalMemoryCacheAdapterProvider.getProvider(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
```
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"name": "@expo/entity-cache-adapter-local-memory",
|
||||||
|
"version": "0.24.0",
|
||||||
|
"description": "Cross-request local memory cache adapter for @expo/entity",
|
||||||
|
"files": [
|
||||||
|
"build",
|
||||||
|
"src"
|
||||||
|
],
|
||||||
|
"main": "build/index.js",
|
||||||
|
"types": "build/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"tsc": "tsc",
|
||||||
|
"clean": "rm -rf build coverage coverage-integration",
|
||||||
|
"lint": "eslint src",
|
||||||
|
"lint-fix": "eslint src --fix",
|
||||||
|
"test": "jest --rootDir . --config ../../resources/jest.config.js",
|
||||||
|
"integration": "../../resources/run-with-docker yarn integration-no-setup",
|
||||||
|
"integration-no-setup": "jest --config ../../resources/jest-integration.config.js --rootDir . --runInBand --passWithNoTests",
|
||||||
|
"barrelsby": "barrelsby --directory src --location top --exclude tests__ --singleQuotes --exportDefault --delete"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"entity"
|
||||||
|
],
|
||||||
|
"author": "Expo",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@expo/entity": "*"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"lru-cache": "^7.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@expo/entity": "^0.24.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { CacheLoadResult, CacheStatus, IEntityGenericCacher } from '@expo/entity';
|
||||||
|
import LRUCache from 'lru-cache';
|
||||||
|
|
||||||
|
// Sentinel value we store in local memory to negatively cache a database miss.
|
||||||
|
// The sentinel value is distinct from any (positively) cached value.
|
||||||
|
export const DOES_NOT_EXIST_LOCAL_MEMORY_CACHE = Symbol('doesNotExist');
|
||||||
|
type LocalMemoryCacheValue<TFields> = Readonly<TFields> | typeof DOES_NOT_EXIST_LOCAL_MEMORY_CACHE;
|
||||||
|
export type LocalMemoryCache<TFields> = LRUCache<string, LocalMemoryCacheValue<TFields>>;
|
||||||
|
|
||||||
|
type LRUCacheOptionsV7<K, V> = {
|
||||||
|
/**
|
||||||
|
* the number of most recently used items to keep.
|
||||||
|
* note that we may store fewer items than this if maxSize is hit.
|
||||||
|
*/
|
||||||
|
max: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* if you wish to track item size, you must provide a maxSize
|
||||||
|
* note that we still will only keep up to max *actual items*,
|
||||||
|
* so size tracking may cause fewer than max items to be stored.
|
||||||
|
* At the extreme, a single item of maxSize size will cause everything
|
||||||
|
* else in the cache to be dropped when it is added. Use with caution!
|
||||||
|
* Note also that size tracking can negatively impact performance,
|
||||||
|
* though for most cases, only minimally.
|
||||||
|
*/
|
||||||
|
|
||||||
|
maxSize?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* function to calculate size of items. useful if storing strings or
|
||||||
|
* buffers or other items where memory size depends on the object itself.
|
||||||
|
* also note that oversized items do NOT immediately get dropped from
|
||||||
|
* the cache, though they will cause faster turnover in the storage.
|
||||||
|
*/
|
||||||
|
sizeCalculation?: (value: V, key: K) => number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* function to call when the item is removed from the cache
|
||||||
|
* Note that using this can negatively impact performance.
|
||||||
|
*/
|
||||||
|
dispose?: (value: V, key: K) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* max time to live for items before they are considered stale
|
||||||
|
* note that stale items are NOT preemptively removed by default,
|
||||||
|
* and MAY live in the cache, contributing to its LRU max, long after
|
||||||
|
* they have expired.
|
||||||
|
* Also, as this cache is optimized for LRU/MRU operations, some of
|
||||||
|
* the staleness/TTL checks will reduce performance, as they will incur
|
||||||
|
* overhead by deleting items.
|
||||||
|
* Must be a positive integer in ms, defaults to 0, which means "no TTL"
|
||||||
|
*/
|
||||||
|
ttl?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* return stale items from cache.get() before disposing of them
|
||||||
|
* boolean, default false
|
||||||
|
*/
|
||||||
|
allowStale?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* update the age of items on cache.get(), renewing their TTL
|
||||||
|
* boolean, default false
|
||||||
|
*/
|
||||||
|
updateAgeOnGet?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* update the age of items on cache.has(), renewing their TTL
|
||||||
|
* boolean, default false
|
||||||
|
*/
|
||||||
|
updateAgeOnHas?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* update the "recently-used"-ness of items on cache.has()
|
||||||
|
* boolean, default false
|
||||||
|
*/
|
||||||
|
updateRecencyOnHas?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default class GenericLocalMemoryCacher<TFields> implements IEntityGenericCacher<TFields> {
|
||||||
|
constructor(private readonly localMemoryCache: LocalMemoryCache<TFields>) {}
|
||||||
|
|
||||||
|
static createLRUCache<TFields>(
|
||||||
|
options: { maxSize?: number; ttlSeconds?: number } = {}
|
||||||
|
): LocalMemoryCache<TFields> {
|
||||||
|
const DEFAULT_LRU_CACHE_MAX_AGE_SECONDS = 10;
|
||||||
|
const DEFAULT_LRU_CACHE_SIZE = 10000;
|
||||||
|
const maxAgeSeconds = options.ttlSeconds ?? DEFAULT_LRU_CACHE_MAX_AGE_SECONDS;
|
||||||
|
const lruCacheOptions: LRUCacheOptionsV7<string, TFields> = {
|
||||||
|
max: options.maxSize ?? DEFAULT_LRU_CACHE_SIZE,
|
||||||
|
sizeCalculation: (value: LocalMemoryCacheValue<TFields>) =>
|
||||||
|
value === DOES_NOT_EXIST_LOCAL_MEMORY_CACHE ? 0 : 1,
|
||||||
|
ttl: maxAgeSeconds * 1000, // convert to ms
|
||||||
|
};
|
||||||
|
return new LRUCache<string, LocalMemoryCacheValue<TFields>>(lruCacheOptions as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
static createNoOpCache<TFields>(): LocalMemoryCache<TFields> {
|
||||||
|
return new LRUCache<string, LocalMemoryCacheValue<TFields>>({
|
||||||
|
max: 0,
|
||||||
|
maxAge: -1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async loadManyAsync(
|
||||||
|
keys: readonly string[]
|
||||||
|
): Promise<ReadonlyMap<string, CacheLoadResult<TFields>>> {
|
||||||
|
const cacheResults = new Map<string, CacheLoadResult<TFields>>();
|
||||||
|
for (const key of keys) {
|
||||||
|
const cacheResult = this.localMemoryCache.get(key);
|
||||||
|
if (cacheResult === DOES_NOT_EXIST_LOCAL_MEMORY_CACHE) {
|
||||||
|
cacheResults.set(key, {
|
||||||
|
status: CacheStatus.NEGATIVE,
|
||||||
|
});
|
||||||
|
} else if (cacheResult) {
|
||||||
|
cacheResults.set(key, {
|
||||||
|
status: CacheStatus.HIT,
|
||||||
|
item: cacheResult as unknown as TFields,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
cacheResults.set(key, {
|
||||||
|
status: CacheStatus.MISS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cacheResults;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async cacheManyAsync(objectMap: ReadonlyMap<string, Readonly<TFields>>): Promise<void> {
|
||||||
|
for (const [key, item] of objectMap) {
|
||||||
|
this.localMemoryCache.set(key, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async cacheDBMissesAsync(keys: readonly string[]): Promise<void> {
|
||||||
|
for (const key of keys) {
|
||||||
|
this.localMemoryCache.set(key, DOES_NOT_EXIST_LOCAL_MEMORY_CACHE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async invalidateManyAsync(keys: readonly string[]): Promise<void> {
|
||||||
|
for (const key of keys) {
|
||||||
|
this.localMemoryCache.del(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public makeCacheKey(parts: string[]): string {
|
||||||
|
const delimiter = ':';
|
||||||
|
const escapedParts = parts.map((part) =>
|
||||||
|
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`)
|
||||||
|
);
|
||||||
|
return escapedParts.join(delimiter);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { EntityCacheAdapter, CacheLoadResult, EntityConfiguration, mapKeys } from '@expo/entity';
|
||||||
|
import invariant from 'invariant';
|
||||||
|
|
||||||
|
import GenericLocalMemoryCacher, { LocalMemoryCache } from './GenericLocalMemoryCacher';
|
||||||
|
|
||||||
|
export default class LocalMemoryCacheAdapter<TFields> extends EntityCacheAdapter<TFields> {
|
||||||
|
private readonly genericLocalMemoryCacher: GenericLocalMemoryCacher<TFields>;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
entityConfiguration: EntityConfiguration<TFields>,
|
||||||
|
localMemoryCache: LocalMemoryCache<TFields>
|
||||||
|
) {
|
||||||
|
super(entityConfiguration);
|
||||||
|
this.genericLocalMemoryCacher = new GenericLocalMemoryCacher(localMemoryCache);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async loadManyAsync<N extends keyof TFields>(
|
||||||
|
fieldName: N,
|
||||||
|
fieldValues: readonly NonNullable<TFields[N]>[]
|
||||||
|
): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult<TFields>>> {
|
||||||
|
const localMemoryCacheKeyToFieldValueMapping = new Map(
|
||||||
|
fieldValues.map((fieldValue) => [this.makeCacheKey(fieldName, fieldValue), fieldValue])
|
||||||
|
);
|
||||||
|
const cacheResults = await this.genericLocalMemoryCacher.loadManyAsync(
|
||||||
|
Array.from(localMemoryCacheKeyToFieldValueMapping.keys())
|
||||||
|
);
|
||||||
|
|
||||||
|
return mapKeys(cacheResults, (cacheKey) => {
|
||||||
|
const fieldValue = localMemoryCacheKeyToFieldValueMapping.get(cacheKey);
|
||||||
|
invariant(
|
||||||
|
fieldValue !== undefined,
|
||||||
|
'Unspecified cache key %s returned from generic local memory cacher',
|
||||||
|
cacheKey
|
||||||
|
);
|
||||||
|
return fieldValue;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async cacheManyAsync<N extends keyof TFields>(
|
||||||
|
fieldName: N,
|
||||||
|
objectMap: ReadonlyMap<NonNullable<TFields[N]>, Readonly<TFields>>
|
||||||
|
): Promise<void> {
|
||||||
|
await this.genericLocalMemoryCacher.cacheManyAsync(
|
||||||
|
mapKeys(objectMap, (fieldValue) => this.makeCacheKey(fieldName, fieldValue))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async cacheDBMissesAsync<N extends keyof TFields>(
|
||||||
|
fieldName: N,
|
||||||
|
fieldValues: readonly NonNullable<TFields[N]>[]
|
||||||
|
): Promise<void> {
|
||||||
|
await this.genericLocalMemoryCacher.cacheDBMissesAsync(
|
||||||
|
fieldValues.map((fieldValue) => this.makeCacheKey(fieldName, fieldValue))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async invalidateManyAsync<N extends keyof TFields>(
|
||||||
|
fieldName: N,
|
||||||
|
fieldValues: readonly NonNullable<TFields[N]>[]
|
||||||
|
): Promise<void> {
|
||||||
|
await this.genericLocalMemoryCacher.invalidateManyAsync(
|
||||||
|
fieldValues.map((fieldValue) => this.makeCacheKey(fieldName, fieldValue))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private makeCacheKey<N extends keyof TFields>(
|
||||||
|
fieldName: N,
|
||||||
|
fieldValue: NonNullable<TFields[N]>
|
||||||
|
): string {
|
||||||
|
const columnName = this.entityConfiguration.entityToDBFieldsKeyMapping.get(fieldName);
|
||||||
|
invariant(columnName, `database field mapping missing for ${fieldName}`);
|
||||||
|
const parts = [
|
||||||
|
this.entityConfiguration.tableName,
|
||||||
|
`${this.entityConfiguration.cacheKeyVersion}`,
|
||||||
|
columnName,
|
||||||
|
String(fieldValue),
|
||||||
|
];
|
||||||
|
return this.genericLocalMemoryCacher.makeCacheKey(parts);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import {
|
||||||
|
computeIfAbsent,
|
||||||
|
EntityCacheAdapter,
|
||||||
|
EntityConfiguration,
|
||||||
|
IEntityCacheAdapterProvider,
|
||||||
|
} from '@expo/entity';
|
||||||
|
|
||||||
|
import GenericLocalMemoryCacher, { LocalMemoryCache } from './GenericLocalMemoryCacher';
|
||||||
|
import LocalMemoryCacheAdapter from './LocalMemoryCacheAdapter';
|
||||||
|
|
||||||
|
export default class LocalMemoryCacheAdapterProvider implements IEntityCacheAdapterProvider {
|
||||||
|
// local memory cache adapters should be shared/reused across requests
|
||||||
|
static localMemoryCacheAdapterMap = new Map<string, LocalMemoryCacheAdapter<any>>();
|
||||||
|
|
||||||
|
static getNoOpProvider(): IEntityCacheAdapterProvider {
|
||||||
|
return new LocalMemoryCacheAdapterProvider(<TFields>() =>
|
||||||
|
GenericLocalMemoryCacher.createNoOpCache<TFields>()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static getProvider(
|
||||||
|
options: { maxSize?: number; ttlSeconds?: number } = {}
|
||||||
|
): IEntityCacheAdapterProvider {
|
||||||
|
return new LocalMemoryCacheAdapterProvider(<TFields>() =>
|
||||||
|
GenericLocalMemoryCacher.createLRUCache<TFields>(options)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private constructor(
|
||||||
|
private readonly localMemoryCacheCreator: <TFields>() => LocalMemoryCache<TFields>
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public getCacheAdapter<TFields>(
|
||||||
|
entityConfiguration: EntityConfiguration<TFields>
|
||||||
|
): EntityCacheAdapter<TFields> {
|
||||||
|
return computeIfAbsent(
|
||||||
|
LocalMemoryCacheAdapterProvider.localMemoryCacheAdapterMap,
|
||||||
|
entityConfiguration.tableName,
|
||||||
|
() => {
|
||||||
|
const localMemoryCache = this.localMemoryCacheCreator<TFields>();
|
||||||
|
return new LocalMemoryCacheAdapter(entityConfiguration, localMemoryCache);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+180
@@ -0,0 +1,180 @@
|
|||||||
|
import { CacheStatus, ViewerContext } from '@expo/entity';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
import GenericLocalMemoryCacher from '../GenericLocalMemoryCacher';
|
||||||
|
import LocalMemoryCacheAdapter from '../LocalMemoryCacheAdapter';
|
||||||
|
import { LocalMemoryCacheAdapterProvider } from '../LocalMemoryCacheAdapterProvider';
|
||||||
|
import LocalMemoryTestEntity from '../testfixtures/LocalMemoryTestEntity';
|
||||||
|
import {
|
||||||
|
createLocalMemoryIntegrationTestEntityCompanionProvider,
|
||||||
|
createNoopLocalMemoryIntegrationTestEntityCompanionProvider,
|
||||||
|
} from '../testfixtures/createLocalMemoryIntegrationTestEntityCompanionProvider';
|
||||||
|
|
||||||
|
class TestViewerContext extends ViewerContext {}
|
||||||
|
|
||||||
|
describe(LocalMemoryCacheAdapter, () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
LocalMemoryCacheAdapterProvider.localMemoryCacheAdapterMap.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has correct caching behavior', async () => {
|
||||||
|
const viewerContext = new TestViewerContext(
|
||||||
|
createLocalMemoryIntegrationTestEntityCompanionProvider()
|
||||||
|
);
|
||||||
|
const cacheAdapter = viewerContext.entityCompanionProvider.getCompanionForEntity(
|
||||||
|
LocalMemoryTestEntity,
|
||||||
|
LocalMemoryTestEntity.getCompanionDefinition()
|
||||||
|
)['tableDataCoordinator']['cacheAdapter'];
|
||||||
|
const cacheKeyMaker = cacheAdapter['makeCacheKey'].bind(cacheAdapter);
|
||||||
|
|
||||||
|
const date = new Date();
|
||||||
|
const entity1Created = await LocalMemoryTestEntity.creator(viewerContext)
|
||||||
|
.setField('name', 'blah')
|
||||||
|
.setField('dateField', date)
|
||||||
|
.enforceCreateAsync();
|
||||||
|
|
||||||
|
// loading an entity should put it in cache
|
||||||
|
const entity1 = await LocalMemoryTestEntity.loader(viewerContext)
|
||||||
|
.enforcing()
|
||||||
|
.loadByIDAsync(entity1Created.getID());
|
||||||
|
|
||||||
|
const entitySpecificGenericCacher =
|
||||||
|
LocalMemoryCacheAdapterProvider.localMemoryCacheAdapterMap.get(
|
||||||
|
LocalMemoryTestEntity.getCompanionDefinition().entityConfiguration.tableName
|
||||||
|
)!['genericLocalMemoryCacher'];
|
||||||
|
const cachedResult = await entitySpecificGenericCacher.loadManyAsync([
|
||||||
|
cacheKeyMaker('id', entity1.getID()),
|
||||||
|
]);
|
||||||
|
const cachedValue = cachedResult.get(cacheKeyMaker('id', entity1.getID()))!;
|
||||||
|
expect(cachedValue).toMatchObject({
|
||||||
|
status: CacheStatus.HIT,
|
||||||
|
item: {
|
||||||
|
id: entity1.getID(),
|
||||||
|
name: 'blah',
|
||||||
|
dateField: date,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// simulate non existent db fetch, should write negative result ('') to cache
|
||||||
|
const nonExistentId = uuidv4();
|
||||||
|
|
||||||
|
const entityNonExistentResult = await LocalMemoryTestEntity.loader(viewerContext).loadByIDAsync(
|
||||||
|
nonExistentId
|
||||||
|
);
|
||||||
|
expect(entityNonExistentResult.ok).toBe(false);
|
||||||
|
|
||||||
|
const nonExistentCachedResult = await entitySpecificGenericCacher.loadManyAsync([
|
||||||
|
cacheKeyMaker('id', nonExistentId),
|
||||||
|
]);
|
||||||
|
expect(nonExistentCachedResult.get(cacheKeyMaker('id', nonExistentId))).toMatchObject({
|
||||||
|
status: CacheStatus.NEGATIVE,
|
||||||
|
});
|
||||||
|
|
||||||
|
// load again through entities framework to ensure it reads negative result
|
||||||
|
const entityNonExistentResult2 = await LocalMemoryTestEntity.loader(
|
||||||
|
viewerContext
|
||||||
|
).loadByIDAsync(nonExistentId);
|
||||||
|
expect(entityNonExistentResult2.ok).toBe(false);
|
||||||
|
|
||||||
|
// invalidate from cache to ensure it invalidates correctly
|
||||||
|
await LocalMemoryTestEntity.loader(viewerContext).invalidateFieldsAsync(entity1.getAllFields());
|
||||||
|
const cachedResultMiss = await entitySpecificGenericCacher.loadManyAsync([
|
||||||
|
cacheKeyMaker('id', entity1.getID()),
|
||||||
|
]);
|
||||||
|
const cachedValueMiss = cachedResultMiss.get(cacheKeyMaker('id', entity1.getID()));
|
||||||
|
expect(cachedValueMiss).toMatchObject({ status: CacheStatus.MISS });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shares the cache between different requests', async () => {
|
||||||
|
const genericLocalMemoryCacherLoadManySpy = jest.spyOn(
|
||||||
|
GenericLocalMemoryCacher.prototype as unknown as any,
|
||||||
|
'loadManyAsync'
|
||||||
|
);
|
||||||
|
const viewerContext = new TestViewerContext(
|
||||||
|
createLocalMemoryIntegrationTestEntityCompanionProvider()
|
||||||
|
);
|
||||||
|
|
||||||
|
const date = new Date();
|
||||||
|
const entity1Created = await LocalMemoryTestEntity.creator(viewerContext)
|
||||||
|
.setField('name', 'blah')
|
||||||
|
.setField('dateField', date)
|
||||||
|
.enforceCreateAsync();
|
||||||
|
|
||||||
|
// loading an entity should put it in cache
|
||||||
|
await LocalMemoryTestEntity.loader(viewerContext)
|
||||||
|
.enforcing()
|
||||||
|
.loadByIDAsync(entity1Created.getID());
|
||||||
|
|
||||||
|
// load entity with a different request
|
||||||
|
const viewerContext2 = new TestViewerContext(
|
||||||
|
createLocalMemoryIntegrationTestEntityCompanionProvider()
|
||||||
|
);
|
||||||
|
const entity1WithVc2 = await LocalMemoryTestEntity.loader(viewerContext2)
|
||||||
|
.enforcing()
|
||||||
|
.loadByIDAsync(entity1Created.getID());
|
||||||
|
|
||||||
|
const cacheAdapter = viewerContext.entityCompanionProvider.getCompanionForEntity(
|
||||||
|
LocalMemoryTestEntity,
|
||||||
|
LocalMemoryTestEntity.getCompanionDefinition()
|
||||||
|
)['tableDataCoordinator']['cacheAdapter'];
|
||||||
|
const cacheKeyMaker = cacheAdapter['makeCacheKey'].bind(cacheAdapter);
|
||||||
|
expect(entity1WithVc2.getAllFields()).toMatchObject({
|
||||||
|
id: entity1WithVc2.getID(),
|
||||||
|
name: 'blah',
|
||||||
|
dateField: date,
|
||||||
|
});
|
||||||
|
expect(genericLocalMemoryCacherLoadManySpy).toBeCalledWith([
|
||||||
|
cacheKeyMaker('id', entity1WithVc2.getID()),
|
||||||
|
]);
|
||||||
|
expect(genericLocalMemoryCacherLoadManySpy).toBeCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects the parameters of a noop cache', async () => {
|
||||||
|
const viewerContext = new TestViewerContext(
|
||||||
|
createNoopLocalMemoryIntegrationTestEntityCompanionProvider()
|
||||||
|
);
|
||||||
|
const cacheAdapter = viewerContext.entityCompanionProvider.getCompanionForEntity(
|
||||||
|
LocalMemoryTestEntity,
|
||||||
|
LocalMemoryTestEntity.getCompanionDefinition()
|
||||||
|
)['tableDataCoordinator']['cacheAdapter'];
|
||||||
|
const cacheKeyMaker = cacheAdapter['makeCacheKey'].bind(cacheAdapter);
|
||||||
|
|
||||||
|
const date = new Date();
|
||||||
|
const entity1Created = await LocalMemoryTestEntity.creator(viewerContext)
|
||||||
|
.setField('name', 'blah')
|
||||||
|
.setField('dateField', date)
|
||||||
|
.enforceCreateAsync();
|
||||||
|
|
||||||
|
// loading an entity will try to put it in cache but it's a noop cache, so it should be a miss
|
||||||
|
const entity1 = await LocalMemoryTestEntity.loader(viewerContext)
|
||||||
|
.enforcing()
|
||||||
|
.loadByIDAsync(entity1Created.getID());
|
||||||
|
|
||||||
|
const entitySpecificGenericCacher =
|
||||||
|
LocalMemoryCacheAdapterProvider.localMemoryCacheAdapterMap.get(
|
||||||
|
LocalMemoryTestEntity.getCompanionDefinition().entityConfiguration.tableName
|
||||||
|
)!['genericLocalMemoryCacher'];
|
||||||
|
const cachedResult = await entitySpecificGenericCacher.loadManyAsync([
|
||||||
|
cacheKeyMaker('id', entity1.getID()),
|
||||||
|
]);
|
||||||
|
const cachedValue = cachedResult.get(cacheKeyMaker('id', entity1.getID()))!;
|
||||||
|
expect(cachedValue).toMatchObject({
|
||||||
|
status: CacheStatus.MISS,
|
||||||
|
});
|
||||||
|
|
||||||
|
// a non existent db fetch should try to write negative result ('') but it's a noop cache, so it should be a miss
|
||||||
|
const nonExistentId = uuidv4();
|
||||||
|
|
||||||
|
const entityNonExistentResult = await LocalMemoryTestEntity.loader(viewerContext).loadByIDAsync(
|
||||||
|
nonExistentId
|
||||||
|
);
|
||||||
|
expect(entityNonExistentResult.ok).toBe(false);
|
||||||
|
|
||||||
|
const nonExistentCachedResult = await entitySpecificGenericCacher.loadManyAsync([
|
||||||
|
cacheKeyMaker('id', nonExistentId),
|
||||||
|
]);
|
||||||
|
expect(nonExistentCachedResult.get(cacheKeyMaker('id', nonExistentId))).toMatchObject({
|
||||||
|
status: CacheStatus.MISS,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+125
@@ -0,0 +1,125 @@
|
|||||||
|
import { CacheStatus, UUIDField, EntityConfiguration } from '@expo/entity';
|
||||||
|
|
||||||
|
import GenericLocalMemoryCacher, {
|
||||||
|
DOES_NOT_EXIST_LOCAL_MEMORY_CACHE,
|
||||||
|
} from '../GenericLocalMemoryCacher';
|
||||||
|
import LocalMemoryCacheAdapter from '../LocalMemoryCacheAdapter';
|
||||||
|
|
||||||
|
type BlahFields = {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const entityConfiguration = new EntityConfiguration<BlahFields>({
|
||||||
|
idField: 'id',
|
||||||
|
tableName: 'blah',
|
||||||
|
schema: {
|
||||||
|
id: new UUIDField({ columnName: 'id', cache: true }),
|
||||||
|
},
|
||||||
|
databaseAdapterFlavor: 'postgres',
|
||||||
|
cacheAdapterFlavor: 'local-memory',
|
||||||
|
});
|
||||||
|
|
||||||
|
describe(LocalMemoryCacheAdapter, () => {
|
||||||
|
describe('loadManyAsync', () => {
|
||||||
|
it('returns appropriate cache results', async () => {
|
||||||
|
const cacheAdapter = new LocalMemoryCacheAdapter(
|
||||||
|
entityConfiguration,
|
||||||
|
GenericLocalMemoryCacher.createLRUCache({
|
||||||
|
maxSize: Number.MAX_SAFE_INTEGER,
|
||||||
|
ttlSeconds: Number.MAX_SAFE_INTEGER,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const cacheHits = new Map<string, Readonly<BlahFields>>([['test-id-1', { id: 'test-id-1' }]]);
|
||||||
|
await cacheAdapter.cacheManyAsync('id', cacheHits);
|
||||||
|
await cacheAdapter.cacheDBMissesAsync('id', ['test-id-2']);
|
||||||
|
|
||||||
|
const results = await cacheAdapter.loadManyAsync('id', [
|
||||||
|
'test-id-1',
|
||||||
|
'test-id-2',
|
||||||
|
'test-id-3',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(results.get('test-id-1')).toMatchObject({
|
||||||
|
status: CacheStatus.HIT,
|
||||||
|
item: { id: 'test-id-1' },
|
||||||
|
});
|
||||||
|
expect(results.get('test-id-2')).toMatchObject({ status: CacheStatus.NEGATIVE });
|
||||||
|
expect(results.get('test-id-3')).toMatchObject({ status: CacheStatus.MISS });
|
||||||
|
expect(results.size).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty map when passed empty array of fieldValues', async () => {
|
||||||
|
const cacheAdapter = new LocalMemoryCacheAdapter(
|
||||||
|
entityConfiguration,
|
||||||
|
GenericLocalMemoryCacher.createLRUCache({
|
||||||
|
maxSize: Number.MAX_SAFE_INTEGER,
|
||||||
|
ttlSeconds: Number.MAX_SAFE_INTEGER,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const results = await cacheAdapter.loadManyAsync('id', []);
|
||||||
|
expect(results).toEqual(new Map());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cacheManyAsync', () => {
|
||||||
|
it('correctly caches all objects', async () => {
|
||||||
|
const localMemoryCache = GenericLocalMemoryCacher.createLRUCache<BlahFields>({
|
||||||
|
maxSize: Number.MAX_SAFE_INTEGER,
|
||||||
|
ttlSeconds: Number.MAX_SAFE_INTEGER,
|
||||||
|
});
|
||||||
|
|
||||||
|
const cacheAdapter = new LocalMemoryCacheAdapter(entityConfiguration, localMemoryCache);
|
||||||
|
await cacheAdapter.cacheManyAsync('id', new Map([['test-id-1', { id: 'test-id-1' }]]));
|
||||||
|
|
||||||
|
const cacheKey = cacheAdapter['makeCacheKey']('id', 'test-id-1');
|
||||||
|
expect(localMemoryCache.get(cacheKey)).toMatchObject({
|
||||||
|
id: 'test-id-1',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cacheDBMissesAsync', () => {
|
||||||
|
it('correctly caches misses', async () => {
|
||||||
|
const localMemoryCache = GenericLocalMemoryCacher.createLRUCache<BlahFields>({
|
||||||
|
maxSize: Number.MAX_SAFE_INTEGER,
|
||||||
|
ttlSeconds: Number.MAX_SAFE_INTEGER,
|
||||||
|
});
|
||||||
|
|
||||||
|
const cacheAdapter = new LocalMemoryCacheAdapter(entityConfiguration, localMemoryCache);
|
||||||
|
await cacheAdapter.cacheDBMissesAsync('id', ['test-id-1']);
|
||||||
|
|
||||||
|
const cacheKey = cacheAdapter['makeCacheKey']('id', 'test-id-1');
|
||||||
|
expect(localMemoryCache.get(cacheKey)).toEqual(DOES_NOT_EXIST_LOCAL_MEMORY_CACHE);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('invalidateManyAsync', () => {
|
||||||
|
it('invalidates correctly', async () => {
|
||||||
|
const localMemoryCache = GenericLocalMemoryCacher.createLRUCache<BlahFields>({
|
||||||
|
maxSize: Number.MAX_SAFE_INTEGER,
|
||||||
|
ttlSeconds: Number.MAX_SAFE_INTEGER,
|
||||||
|
});
|
||||||
|
|
||||||
|
const cacheAdapter = new LocalMemoryCacheAdapter(entityConfiguration, localMemoryCache);
|
||||||
|
await cacheAdapter.cacheManyAsync('id', new Map([['test-id-1', { id: 'test-id-1' }]]));
|
||||||
|
await cacheAdapter.cacheDBMissesAsync('id', ['test-id-2']);
|
||||||
|
await cacheAdapter.invalidateManyAsync('id', ['test-id-1', 'test-id-2']);
|
||||||
|
|
||||||
|
const results = await cacheAdapter.loadManyAsync('id', ['test-id-1', 'test-id-2']);
|
||||||
|
expect(results.get('test-id-1')).toMatchObject({ status: CacheStatus.MISS });
|
||||||
|
expect(results.get('test-id-2')).toMatchObject({ status: CacheStatus.MISS });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns when passed empty array of fieldValues', async () => {
|
||||||
|
const cacheAdapter = new LocalMemoryCacheAdapter(
|
||||||
|
entityConfiguration,
|
||||||
|
GenericLocalMemoryCacher.createLRUCache<BlahFields>({
|
||||||
|
maxSize: Number.MAX_SAFE_INTEGER,
|
||||||
|
ttlSeconds: Number.MAX_SAFE_INTEGER,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await cacheAdapter.invalidateManyAsync('id', []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/* eslint-disable tsdoc/syntax */
|
||||||
|
/**
|
||||||
|
* @packageDocumentation
|
||||||
|
* @module @expo/entity-cache-adapter-local-memory
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { default as LocalMemoryCacheAdapter } from './LocalMemoryCacheAdapter';
|
||||||
|
export { default as LocalMemoryCacheAdapterProvider } from './LocalMemoryCacheAdapterProvider';
|
||||||
|
export { default as GenericLocalMemoryCacher } from './GenericLocalMemoryCacher';
|
||||||
|
export * from './GenericLocalMemoryCacher';
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import {
|
||||||
|
AlwaysAllowPrivacyPolicyRule,
|
||||||
|
EntityPrivacyPolicy,
|
||||||
|
ViewerContext,
|
||||||
|
UUIDField,
|
||||||
|
DateField,
|
||||||
|
StringField,
|
||||||
|
EntityConfiguration,
|
||||||
|
EntityCompanionDefinition,
|
||||||
|
Entity,
|
||||||
|
} from '@expo/entity';
|
||||||
|
|
||||||
|
export type LocalMemoryTestEntityFields = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
dateField: Date | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default class LocalMemoryTestEntity extends Entity<
|
||||||
|
LocalMemoryTestEntityFields,
|
||||||
|
string,
|
||||||
|
ViewerContext
|
||||||
|
> {
|
||||||
|
static getCompanionDefinition(): EntityCompanionDefinition<
|
||||||
|
LocalMemoryTestEntityFields,
|
||||||
|
string,
|
||||||
|
ViewerContext,
|
||||||
|
LocalMemoryTestEntity,
|
||||||
|
LocalMemoryTestEntityPrivacyPolicy
|
||||||
|
> {
|
||||||
|
return localMemoryTestEntityCompanionDefinition;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LocalMemoryTestEntityPrivacyPolicy extends EntityPrivacyPolicy<
|
||||||
|
LocalMemoryTestEntityFields,
|
||||||
|
string,
|
||||||
|
ViewerContext,
|
||||||
|
LocalMemoryTestEntity
|
||||||
|
> {
|
||||||
|
protected override readonly createRules = [
|
||||||
|
new AlwaysAllowPrivacyPolicyRule<
|
||||||
|
LocalMemoryTestEntityFields,
|
||||||
|
string,
|
||||||
|
ViewerContext,
|
||||||
|
LocalMemoryTestEntity
|
||||||
|
>(),
|
||||||
|
];
|
||||||
|
protected override readonly readRules = [
|
||||||
|
new AlwaysAllowPrivacyPolicyRule<
|
||||||
|
LocalMemoryTestEntityFields,
|
||||||
|
string,
|
||||||
|
ViewerContext,
|
||||||
|
LocalMemoryTestEntity
|
||||||
|
>(),
|
||||||
|
];
|
||||||
|
protected override readonly updateRules = [
|
||||||
|
new AlwaysAllowPrivacyPolicyRule<
|
||||||
|
LocalMemoryTestEntityFields,
|
||||||
|
string,
|
||||||
|
ViewerContext,
|
||||||
|
LocalMemoryTestEntity
|
||||||
|
>(),
|
||||||
|
];
|
||||||
|
protected override readonly deleteRules = [
|
||||||
|
new AlwaysAllowPrivacyPolicyRule<
|
||||||
|
LocalMemoryTestEntityFields,
|
||||||
|
string,
|
||||||
|
ViewerContext,
|
||||||
|
LocalMemoryTestEntity
|
||||||
|
>(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const localMemoryTestEntityConfiguration =
|
||||||
|
new EntityConfiguration<LocalMemoryTestEntityFields>({
|
||||||
|
idField: 'id',
|
||||||
|
tableName: 'local_memory_test_entities',
|
||||||
|
schema: {
|
||||||
|
id: new UUIDField({
|
||||||
|
columnName: 'id',
|
||||||
|
cache: true,
|
||||||
|
}),
|
||||||
|
name: new StringField({
|
||||||
|
columnName: 'name',
|
||||||
|
cache: true,
|
||||||
|
}),
|
||||||
|
dateField: new DateField({
|
||||||
|
columnName: 'date_field',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
databaseAdapterFlavor: 'postgres',
|
||||||
|
cacheAdapterFlavor: 'local-memory',
|
||||||
|
});
|
||||||
|
|
||||||
|
const localMemoryTestEntityCompanionDefinition = new EntityCompanionDefinition({
|
||||||
|
entityClass: LocalMemoryTestEntity,
|
||||||
|
entityConfiguration: localMemoryTestEntityConfiguration,
|
||||||
|
privacyPolicyClass: LocalMemoryTestEntityPrivacyPolicy,
|
||||||
|
});
|
||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
import {
|
||||||
|
NoOpEntityMetricsAdapter,
|
||||||
|
IEntityMetricsAdapter,
|
||||||
|
EntityCompanionProvider,
|
||||||
|
StubQueryContextProvider,
|
||||||
|
StubDatabaseAdapterProvider,
|
||||||
|
} from '@expo/entity';
|
||||||
|
|
||||||
|
import { LocalMemoryCacheAdapterProvider } from '../LocalMemoryCacheAdapterProvider';
|
||||||
|
|
||||||
|
export const createLocalMemoryIntegrationTestEntityCompanionProvider = (
|
||||||
|
localMemoryOptions: { maxSize?: number; ttlSeconds?: number } = {},
|
||||||
|
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter()
|
||||||
|
): EntityCompanionProvider => {
|
||||||
|
const localMemoryCacheAdapterProvider =
|
||||||
|
localMemoryOptions.maxSize === 0 && localMemoryOptions.ttlSeconds === 0
|
||||||
|
? LocalMemoryCacheAdapterProvider.getNoOpProvider()
|
||||||
|
: LocalMemoryCacheAdapterProvider.getProvider(localMemoryOptions);
|
||||||
|
return new EntityCompanionProvider(
|
||||||
|
metricsAdapter,
|
||||||
|
new Map([
|
||||||
|
[
|
||||||
|
'postgres',
|
||||||
|
{
|
||||||
|
adapterProvider: new StubDatabaseAdapterProvider(),
|
||||||
|
queryContextProvider: StubQueryContextProvider,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
new Map([
|
||||||
|
[
|
||||||
|
'local-memory',
|
||||||
|
{
|
||||||
|
cacheAdapterProvider: localMemoryCacheAdapterProvider,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
])
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createNoopLocalMemoryIntegrationTestEntityCompanionProvider = (
|
||||||
|
metricsAdapter: IEntityMetricsAdapter = new NoOpEntityMetricsAdapter()
|
||||||
|
): EntityCompanionProvider => {
|
||||||
|
return createLocalMemoryIntegrationTestEntityCompanionProvider(
|
||||||
|
{ maxSize: 0, ttlSeconds: 0 },
|
||||||
|
metricsAdapter
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "build",
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -3,6 +3,57 @@
|
|||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||||
|
|
||||||
|
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
|
||||||
|
|
||||||
|
### Chores
|
||||||
|
|
||||||
|
* Refactor cachers to use an abstract class and interface ([#157](https://github.com/expo/entity/pull/157))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# [0.23.0](https://github.com/expo/entity/compare/v0.22.0...v0.23.0) (2022-02-09)
|
||||||
|
|
||||||
|
### Chores
|
||||||
|
|
||||||
|
* Move data transforming responsibilities to the CacheAdapter instead of the ReadThroughEntityCache. ([#153](https://github.com/expo/entity/pull/153))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
|
||||||
|
|
||||||
|
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# [0.21.0](https://github.com/expo/entity/compare/v0.20.0...v0.21.0) (2022-01-03)
|
||||||
|
|
||||||
|
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# [0.20.0](https://github.com/expo/entity/compare/v0.19.0...v0.20.0) (2021-12-29)
|
||||||
|
|
||||||
|
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# [0.19.0](https://github.com/expo/entity/compare/v0.18.0...v0.19.0) (2021-10-28)
|
||||||
|
|
||||||
|
**Note:** Version bump only for package @expo/entity-cache-adapter-redis
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# [0.18.0](https://github.com/expo/entity/compare/v0.17.0...v0.18.0) (2021-10-14)
|
# [0.18.0](https://github.com/expo/entity/compare/v0.17.0...v0.18.0) (2021-10-14)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@expo/entity-cache-adapter-redis",
|
"name": "@expo/entity-cache-adapter-redis",
|
||||||
"version": "0.18.0",
|
"version": "0.24.0",
|
||||||
"description": "Redis cache adapter for @expo/entity",
|
"description": "Redis cache adapter for @expo/entity",
|
||||||
"files": [
|
"files": [
|
||||||
"build",
|
"build",
|
||||||
@@ -33,6 +33,6 @@
|
|||||||
"ioredis": "^4.27.3"
|
"ioredis": "^4.27.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@expo/entity": "^0.18.0"
|
"@expo/entity": "^0.24.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,14 @@
|
|||||||
import { CacheLoadResult, CacheStatus } from '@expo/entity';
|
import {
|
||||||
|
CacheLoadResult,
|
||||||
|
CacheStatus,
|
||||||
|
EntityConfiguration,
|
||||||
|
transformCacheObjectToFields,
|
||||||
|
transformFieldsToCacheObject,
|
||||||
|
IEntityGenericCacher,
|
||||||
|
} from '@expo/entity';
|
||||||
import { Redis } from 'ioredis';
|
import { Redis } from 'ioredis';
|
||||||
|
|
||||||
|
import { redisTransformerMap } from './RedisCommon';
|
||||||
import wrapNativeRedisCallAsync from './errors/wrapNativeRedisCallAsync';
|
import wrapNativeRedisCallAsync from './errors/wrapNativeRedisCallAsync';
|
||||||
|
|
||||||
// Sentinel value we store in Redis to negatively cache a database miss.
|
// Sentinel value we store in Redis to negatively cache a database miss.
|
||||||
@@ -26,12 +34,15 @@ export interface GenericRedisCacheContext {
|
|||||||
ttlSecondsNegative: number;
|
ttlSecondsNegative: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class GenericRedisCacher {
|
export default class GenericRedisCacher<TFields> implements IEntityGenericCacher<TFields> {
|
||||||
constructor(private readonly context: GenericRedisCacheContext) {}
|
constructor(
|
||||||
|
private readonly context: GenericRedisCacheContext,
|
||||||
|
private readonly entityConfiguration: EntityConfiguration<TFields>
|
||||||
|
) {}
|
||||||
|
|
||||||
public async loadManyAsync(
|
public async loadManyAsync(
|
||||||
keys: readonly string[]
|
keys: readonly string[]
|
||||||
): Promise<ReadonlyMap<string, CacheLoadResult>> {
|
): Promise<ReadonlyMap<string, CacheLoadResult<TFields>>> {
|
||||||
if (keys.length === 0) {
|
if (keys.length === 0) {
|
||||||
return new Map();
|
return new Map();
|
||||||
}
|
}
|
||||||
@@ -40,7 +51,7 @@ export default class GenericRedisCacher {
|
|||||||
this.context.redisClient.mget(...keys)
|
this.context.redisClient.mget(...keys)
|
||||||
);
|
);
|
||||||
|
|
||||||
const results = new Map<string, CacheLoadResult>();
|
const results = new Map<string, CacheLoadResult<TFields>>();
|
||||||
for (let i = 0; i < keys.length; i++) {
|
for (let i = 0; i < keys.length; i++) {
|
||||||
const key = keys[i]!;
|
const key = keys[i]!;
|
||||||
const redisResult = redisResults[i];
|
const redisResult = redisResults[i];
|
||||||
@@ -52,7 +63,11 @@ export default class GenericRedisCacher {
|
|||||||
} else if (redisResult) {
|
} else if (redisResult) {
|
||||||
results.set(key, {
|
results.set(key, {
|
||||||
status: CacheStatus.HIT,
|
status: CacheStatus.HIT,
|
||||||
item: JSON.parse(redisResult),
|
item: transformCacheObjectToFields(
|
||||||
|
this.entityConfiguration,
|
||||||
|
redisTransformerMap,
|
||||||
|
JSON.parse(redisResult)
|
||||||
|
),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
results.set(key, {
|
results.set(key, {
|
||||||
@@ -63,7 +78,7 @@ export default class GenericRedisCacher {
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async cacheManyAsync(objectMap: ReadonlyMap<string, object>): Promise<void> {
|
public async cacheManyAsync(objectMap: ReadonlyMap<string, Readonly<TFields>>): Promise<void> {
|
||||||
if (objectMap.size === 0) {
|
if (objectMap.size === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -72,7 +87,9 @@ export default class GenericRedisCacher {
|
|||||||
objectMap.forEach((object, key) => {
|
objectMap.forEach((object, key) => {
|
||||||
redisTransaction = redisTransaction.set(
|
redisTransaction = redisTransaction.set(
|
||||||
key,
|
key,
|
||||||
JSON.stringify(object),
|
JSON.stringify(
|
||||||
|
transformFieldsToCacheObject(this.entityConfiguration, redisTransformerMap, object)
|
||||||
|
),
|
||||||
'EX',
|
'EX',
|
||||||
this.context.ttlSecondsPositive
|
this.context.ttlSecondsPositive
|
||||||
);
|
);
|
||||||
@@ -80,7 +97,7 @@ export default class GenericRedisCacher {
|
|||||||
await wrapNativeRedisCallAsync(() => redisTransaction.exec());
|
await wrapNativeRedisCallAsync(() => redisTransaction.exec());
|
||||||
}
|
}
|
||||||
|
|
||||||
public async cacheDBMissesAsync(keys: string[]): Promise<void> {
|
public async cacheDBMissesAsync(keys: readonly string[]): Promise<void> {
|
||||||
if (keys.length === 0) {
|
if (keys.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -97,7 +114,7 @@ export default class GenericRedisCacher {
|
|||||||
await wrapNativeRedisCallAsync(() => redisTransaction.exec());
|
await wrapNativeRedisCallAsync(() => redisTransaction.exec());
|
||||||
}
|
}
|
||||||
|
|
||||||
public async invalidateManyAsync(keys: string[]): Promise<void> {
|
public async invalidateManyAsync(keys: readonly string[]): Promise<void> {
|
||||||
if (keys.length === 0) {
|
if (keys.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,8 @@
|
|||||||
import {
|
import { EntityCacheAdapter, EntityConfiguration, CacheLoadResult, mapKeys } from '@expo/entity';
|
||||||
EntityCacheAdapter,
|
|
||||||
EntityConfiguration,
|
|
||||||
CacheLoadResult,
|
|
||||||
FieldTransformerMap,
|
|
||||||
mapKeys,
|
|
||||||
} from '@expo/entity';
|
|
||||||
import invariant from 'invariant';
|
import invariant from 'invariant';
|
||||||
import { Redis } from 'ioredis';
|
import { Redis } from 'ioredis';
|
||||||
|
|
||||||
import GenericRedisCacher from './GenericRedisCacher';
|
import GenericRedisCacher from './GenericRedisCacher';
|
||||||
import { redisTransformerMap } from './RedisCommon';
|
|
||||||
|
|
||||||
export interface RedisCacheAdapterContext {
|
export interface RedisCacheAdapterContext {
|
||||||
/**
|
/**
|
||||||
@@ -49,28 +42,27 @@ export interface RedisCacheAdapterContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default class RedisCacheAdapter<TFields> extends EntityCacheAdapter<TFields> {
|
export default class RedisCacheAdapter<TFields> extends EntityCacheAdapter<TFields> {
|
||||||
private readonly genericRedisCacher: GenericRedisCacher;
|
private readonly genericRedisCacher: GenericRedisCacher<TFields>;
|
||||||
constructor(
|
constructor(
|
||||||
entityConfiguration: EntityConfiguration<TFields>,
|
entityConfiguration: EntityConfiguration<TFields>,
|
||||||
private readonly context: RedisCacheAdapterContext
|
private readonly context: RedisCacheAdapterContext
|
||||||
) {
|
) {
|
||||||
super(entityConfiguration);
|
super(entityConfiguration);
|
||||||
|
|
||||||
this.genericRedisCacher = new GenericRedisCacher({
|
this.genericRedisCacher = new GenericRedisCacher(
|
||||||
redisClient: context.redisClient,
|
{
|
||||||
ttlSecondsNegative: context.ttlSecondsNegative,
|
redisClient: context.redisClient,
|
||||||
ttlSecondsPositive: context.ttlSecondsPositive,
|
ttlSecondsNegative: context.ttlSecondsNegative,
|
||||||
});
|
ttlSecondsPositive: context.ttlSecondsPositive,
|
||||||
}
|
},
|
||||||
|
entityConfiguration
|
||||||
public getFieldTransformerMap(): FieldTransformerMap {
|
);
|
||||||
return redisTransformerMap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async loadManyAsync<N extends keyof TFields>(
|
public async loadManyAsync<N extends keyof TFields>(
|
||||||
fieldName: N,
|
fieldName: N,
|
||||||
fieldValues: readonly NonNullable<TFields[N]>[]
|
fieldValues: readonly NonNullable<TFields[N]>[]
|
||||||
): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult>> {
|
): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult<TFields>>> {
|
||||||
const redisCacheKeyToFieldValueMapping = new Map(
|
const redisCacheKeyToFieldValueMapping = new Map(
|
||||||
fieldValues.map((fieldValue) => [this.makeCacheKey(fieldName, fieldValue), fieldValue])
|
fieldValues.map((fieldValue) => [this.makeCacheKey(fieldName, fieldValue), fieldValue])
|
||||||
);
|
);
|
||||||
@@ -91,7 +83,7 @@ export default class RedisCacheAdapter<TFields> extends EntityCacheAdapter<TFiel
|
|||||||
|
|
||||||
public async cacheManyAsync<N extends keyof TFields>(
|
public async cacheManyAsync<N extends keyof TFields>(
|
||||||
fieldName: N,
|
fieldName: N,
|
||||||
objectMap: ReadonlyMap<NonNullable<TFields[N]>, object>
|
objectMap: ReadonlyMap<NonNullable<TFields[N]>, Readonly<TFields>>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await this.genericRedisCacher.cacheManyAsync(
|
await this.genericRedisCacher.cacheManyAsync(
|
||||||
mapKeys(objectMap, (fieldValue) => this.makeCacheKey(fieldName, fieldValue))
|
mapKeys(objectMap, (fieldValue) => this.makeCacheKey(fieldName, fieldValue))
|
||||||
|
|||||||
+125
@@ -0,0 +1,125 @@
|
|||||||
|
import { CacheStatus, ViewerContext } from '@expo/entity';
|
||||||
|
import Redis from 'ioredis';
|
||||||
|
import { URL } from 'url';
|
||||||
|
|
||||||
|
import GenericRedisCacher from '../GenericRedisCacher';
|
||||||
|
import { RedisCacheAdapterContext } from '../RedisCacheAdapter';
|
||||||
|
import RedisTestEntity, {
|
||||||
|
redisTestEntityConfiguration,
|
||||||
|
RedisTestEntityFields,
|
||||||
|
} from '../testfixtures/RedisTestEntity';
|
||||||
|
import { createRedisIntegrationTestEntityCompanionProvider } from '../testfixtures/createRedisIntegrationTestEntityCompanionProvider';
|
||||||
|
|
||||||
|
class TestViewerContext extends ViewerContext {}
|
||||||
|
|
||||||
|
describe(GenericRedisCacher, () => {
|
||||||
|
let redisCacheAdapterContext: RedisCacheAdapterContext;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
redisCacheAdapterContext = {
|
||||||
|
redisClient: new Redis(new URL(process.env['REDIS_URL']!).toString()),
|
||||||
|
makeKeyFn(...parts: string[]): string {
|
||||||
|
const delimiter = ':';
|
||||||
|
const escapedParts = parts.map((part) =>
|
||||||
|
part.replace('\\', '\\\\').replace(delimiter, `\\${delimiter}`)
|
||||||
|
);
|
||||||
|
return escapedParts.join(delimiter);
|
||||||
|
},
|
||||||
|
cacheKeyPrefix: 'test-',
|
||||||
|
cacheKeyVersion: 1,
|
||||||
|
ttlSecondsPositive: 86400, // 1 day
|
||||||
|
ttlSecondsNegative: 600, // 10 minutes
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await redisCacheAdapterContext.redisClient.flushdb();
|
||||||
|
});
|
||||||
|
afterAll(async () => {
|
||||||
|
redisCacheAdapterContext.redisClient.disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has correct caching and loading behavior', async () => {
|
||||||
|
const viewerContext = new TestViewerContext(
|
||||||
|
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
|
||||||
|
);
|
||||||
|
const genericRedisCacher = new GenericRedisCacher(
|
||||||
|
{
|
||||||
|
redisClient: redisCacheAdapterContext.redisClient,
|
||||||
|
ttlSecondsNegative: redisCacheAdapterContext.ttlSecondsNegative,
|
||||||
|
ttlSecondsPositive: redisCacheAdapterContext.ttlSecondsPositive,
|
||||||
|
},
|
||||||
|
redisTestEntityConfiguration
|
||||||
|
);
|
||||||
|
const date = new Date();
|
||||||
|
const entity1Created = await RedisTestEntity.creator(viewerContext)
|
||||||
|
.setField('name', 'blah')
|
||||||
|
.setField('dateField', date)
|
||||||
|
.enforceCreateAsync();
|
||||||
|
const testKey = `test-id-key-${entity1Created.getID()}`;
|
||||||
|
const objectMap = new Map<string, Readonly<RedisTestEntityFields>>([
|
||||||
|
[testKey, entity1Created.getAllFields()],
|
||||||
|
]);
|
||||||
|
await genericRedisCacher.cacheManyAsync(objectMap);
|
||||||
|
|
||||||
|
const cachedJSON = await redisCacheAdapterContext.redisClient.get(testKey);
|
||||||
|
const cachedValue = JSON.parse(cachedJSON!);
|
||||||
|
expect(cachedValue).toMatchObject({
|
||||||
|
id: entity1Created.getID(),
|
||||||
|
dateField: date.toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadedObjectMap = await genericRedisCacher.loadManyAsync([testKey]);
|
||||||
|
const cacheLoadResult = loadedObjectMap.get(testKey)!;
|
||||||
|
expect(cacheLoadResult).toMatchObject({
|
||||||
|
status: CacheStatus.HIT,
|
||||||
|
item: entity1Created.getAllFields(),
|
||||||
|
});
|
||||||
|
expect(loadedObjectMap.size).toBe(1);
|
||||||
|
});
|
||||||
|
it('has correct negative caching behaviour', async () => {
|
||||||
|
const genericRedisCacher = new GenericRedisCacher(
|
||||||
|
{
|
||||||
|
redisClient: redisCacheAdapterContext.redisClient,
|
||||||
|
ttlSecondsNegative: redisCacheAdapterContext.ttlSecondsNegative,
|
||||||
|
ttlSecondsPositive: redisCacheAdapterContext.ttlSecondsPositive,
|
||||||
|
},
|
||||||
|
redisTestEntityConfiguration
|
||||||
|
);
|
||||||
|
|
||||||
|
const testKey = `test-id-key-non-existent-id`;
|
||||||
|
await genericRedisCacher.cacheDBMissesAsync([testKey]);
|
||||||
|
const loadedObjectMap = await genericRedisCacher.loadManyAsync([testKey]);
|
||||||
|
const cacheLoadResult = loadedObjectMap.get(testKey)!;
|
||||||
|
expect(cacheLoadResult.status).toBe(CacheStatus.NEGATIVE);
|
||||||
|
});
|
||||||
|
it('has correct invalidation behaviour', async () => {
|
||||||
|
const viewerContext = new TestViewerContext(
|
||||||
|
createRedisIntegrationTestEntityCompanionProvider(redisCacheAdapterContext)
|
||||||
|
);
|
||||||
|
const genericRedisCacher = new GenericRedisCacher(
|
||||||
|
{
|
||||||
|
redisClient: redisCacheAdapterContext.redisClient,
|
||||||
|
ttlSecondsNegative: redisCacheAdapterContext.ttlSecondsNegative,
|
||||||
|
ttlSecondsPositive: redisCacheAdapterContext.ttlSecondsPositive,
|
||||||
|
},
|
||||||
|
redisTestEntityConfiguration
|
||||||
|
);
|
||||||
|
const date = new Date();
|
||||||
|
const entity1Created = await RedisTestEntity.creator(viewerContext)
|
||||||
|
.setField('name', 'blah')
|
||||||
|
.setField('dateField', date)
|
||||||
|
.enforceCreateAsync();
|
||||||
|
const testKey = `test-id-key-${entity1Created.getID()}`;
|
||||||
|
const objectMap = new Map<string, Readonly<RedisTestEntityFields>>([
|
||||||
|
[testKey, entity1Created.getAllFields()],
|
||||||
|
]);
|
||||||
|
await genericRedisCacher.cacheManyAsync(objectMap);
|
||||||
|
|
||||||
|
await genericRedisCacher.invalidateManyAsync([testKey]);
|
||||||
|
|
||||||
|
const loadedObjectMap = await genericRedisCacher.loadManyAsync([testKey]);
|
||||||
|
const cacheLoadResult = loadedObjectMap.get(testKey)!;
|
||||||
|
expect(cacheLoadResult.status).toBe(CacheStatus.MISS);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,63 @@
|
|||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||||
|
|
||||||
|
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
|
||||||
|
|
||||||
|
**Note:** Version bump only for package @expo/entity-database-adapter-knex
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# [0.23.0](https://github.com/expo/entity/compare/v0.22.0...v0.23.0) (2022-02-09)
|
||||||
|
|
||||||
|
**Note:** Version bump only for package @expo/entity-database-adapter-knex
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* Upgrade knex to 1.x in `@expo/entity-database-adapter-knex` ([#150](https://github.com/expo/entity/issues/150)) ([e1b0ee5](https://github.com/expo/entity/commit/e1b0ee528175646239d88aa71ffce73983eeb52f))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# [0.21.0](https://github.com/expo/entity/compare/v0.20.0...v0.21.0) (2022-01-03)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add pre-commit callbacks on EntityQueryContext ([#147](https://github.com/expo/entity/issues/147)) ([f1d9847](https://github.com/expo/entity/commit/f1d9847210f5775de964509e44ce493e44482b21))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# [0.20.0](https://github.com/expo/entity/compare/v0.19.0...v0.20.0) (2021-12-29)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add cascading deletion info ([#145](https://github.com/expo/entity/issues/145)) ([3727191](https://github.com/expo/entity/commit/372719176742d3ead6b7cbca29cb66b09b3fe09c))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# [0.19.0](https://github.com/expo/entity/compare/v0.18.0...v0.19.0) (2021-10-28)
|
||||||
|
|
||||||
|
**Note:** Version bump only for package @expo/entity-database-adapter-knex
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# [0.18.0](https://github.com/expo/entity/compare/v0.17.0...v0.18.0) (2021-10-14)
|
# [0.18.0](https://github.com/expo/entity/compare/v0.17.0...v0.18.0) (2021-10-14)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@expo/entity-database-adapter-knex",
|
"name": "@expo/entity-database-adapter-knex",
|
||||||
"version": "0.18.0",
|
"version": "0.24.0",
|
||||||
"description": "Knex database adapter for @expo/entity",
|
"description": "Knex database adapter for @expo/entity",
|
||||||
"files": [
|
"files": [
|
||||||
"build",
|
"build",
|
||||||
@@ -30,9 +30,10 @@
|
|||||||
"@expo/entity": "*"
|
"@expo/entity": "*"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"knex": "^0.95.6"
|
"knex": "^1.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@expo/entity": "^0.18.0"
|
"@expo/entity": "^0.24.0",
|
||||||
|
"pg": "^8.7.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+33
@@ -600,4 +600,37 @@ describe('postgres entity integration', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('queryContext callback behavior', () => {
|
||||||
|
it('calls callbacks correctly', async () => {
|
||||||
|
const vc1 = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance));
|
||||||
|
|
||||||
|
let preCommitCallCount = 0;
|
||||||
|
let postCommitCallCount = 0;
|
||||||
|
|
||||||
|
await vc1.runInTransactionForDatabaseAdaptorFlavorAsync('postgres', async (queryContext) => {
|
||||||
|
queryContext.appendPostCommitCallback(async () => {
|
||||||
|
postCommitCallCount++;
|
||||||
|
});
|
||||||
|
queryContext.appendPreCommitCallback(async () => {
|
||||||
|
preCommitCallCount++;
|
||||||
|
}, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
vc1.runInTransactionForDatabaseAdaptorFlavorAsync('postgres', async (queryContext) => {
|
||||||
|
queryContext.appendPostCommitCallback(async () => {
|
||||||
|
postCommitCallCount++;
|
||||||
|
});
|
||||||
|
queryContext.appendPreCommitCallback(async () => {
|
||||||
|
preCommitCallCount++;
|
||||||
|
throw Error('wat');
|
||||||
|
}, 0);
|
||||||
|
})
|
||||||
|
).rejects.toThrowError('wat');
|
||||||
|
|
||||||
|
expect(preCommitCallCount).toBe(2);
|
||||||
|
expect(postCommitCallCount).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
EntityMutationTrigger,
|
EntityMutationTrigger,
|
||||||
EntityQueryContext,
|
EntityQueryContext,
|
||||||
EntityNonTransactionalMutationTrigger,
|
EntityNonTransactionalMutationTrigger,
|
||||||
EntityMutationInfo,
|
EntityTriggerMutationInfo,
|
||||||
} from '@expo/entity';
|
} from '@expo/entity';
|
||||||
import { Knex } from 'knex';
|
import { Knex } from 'knex';
|
||||||
|
|
||||||
@@ -111,7 +111,7 @@ class ThrowConditionallyTrigger extends EntityMutationTrigger<
|
|||||||
_viewerContext: ViewerContext,
|
_viewerContext: ViewerContext,
|
||||||
_queryContext: EntityQueryContext,
|
_queryContext: EntityQueryContext,
|
||||||
entity: PostgresTriggerTestEntity,
|
entity: PostgresTriggerTestEntity,
|
||||||
_mutationInfo: EntityMutationInfo<
|
_mutationInfo: EntityTriggerMutationInfo<
|
||||||
PostgresTriggerTestEntityFields,
|
PostgresTriggerTestEntityFields,
|
||||||
string,
|
string,
|
||||||
ViewerContext,
|
ViewerContext,
|
||||||
|
|||||||
+2
-2
@@ -9,7 +9,7 @@ import {
|
|||||||
Entity,
|
Entity,
|
||||||
EntityMutationTrigger,
|
EntityMutationTrigger,
|
||||||
EntityQueryContext,
|
EntityQueryContext,
|
||||||
EntityMutationInfo,
|
EntityValidatorMutationInfo,
|
||||||
} from '@expo/entity';
|
} from '@expo/entity';
|
||||||
import { Knex } from 'knex';
|
import { Knex } from 'knex';
|
||||||
|
|
||||||
@@ -113,7 +113,7 @@ class ThrowConditionallyTrigger extends EntityMutationTrigger<
|
|||||||
_viewerContext: ViewerContext,
|
_viewerContext: ViewerContext,
|
||||||
_queryContext: EntityQueryContext,
|
_queryContext: EntityQueryContext,
|
||||||
entity: PostgresValidatorTestEntity,
|
entity: PostgresValidatorTestEntity,
|
||||||
_mutationInfo: EntityMutationInfo<
|
_mutationInfo: EntityValidatorMutationInfo<
|
||||||
PostgresValidatorTestEntityFields,
|
PostgresValidatorTestEntityFields,
|
||||||
string,
|
string,
|
||||||
ViewerContext,
|
ViewerContext,
|
||||||
|
|||||||
@@ -3,6 +3,54 @@
|
|||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||||
|
|
||||||
|
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
|
||||||
|
|
||||||
|
**Note:** Version bump only for package @expo/entity-example
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# [0.23.0](https://github.com/expo/entity/compare/v0.22.0...v0.23.0) (2022-02-09)
|
||||||
|
|
||||||
|
**Note:** Version bump only for package @expo/entity-example
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
|
||||||
|
|
||||||
|
**Note:** Version bump only for package @expo/entity-example
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# [0.21.0](https://github.com/expo/entity/compare/v0.20.0...v0.21.0) (2022-01-03)
|
||||||
|
|
||||||
|
**Note:** Version bump only for package @expo/entity-example
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# [0.20.0](https://github.com/expo/entity/compare/v0.19.0...v0.20.0) (2021-12-29)
|
||||||
|
|
||||||
|
**Note:** Version bump only for package @expo/entity-example
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# [0.19.0](https://github.com/expo/entity/compare/v0.18.0...v0.19.0) (2021-10-28)
|
||||||
|
|
||||||
|
**Note:** Version bump only for package @expo/entity-example
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# [0.18.0](https://github.com/expo/entity/compare/v0.17.0...v0.18.0) (2021-10-14)
|
# [0.18.0](https://github.com/expo/entity/compare/v0.17.0...v0.18.0) (2021-10-14)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@expo/entity-example",
|
"name": "@expo/entity-example",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.18.0",
|
"version": "0.24.0",
|
||||||
"description": "An example integration of the @expo/entity framework",
|
"description": "An example integration of the @expo/entity framework",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"tsc": "tsc",
|
"tsc": "tsc",
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
"author": "Expo",
|
"author": "Expo",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/entity": "^0.18.0",
|
"@expo/entity": "^0.24.0",
|
||||||
"apollo-server-koa": "^2.16.1",
|
"apollo-server-koa": "^2.16.1",
|
||||||
"graphql": "^15.3.0",
|
"graphql": "^15.3.0",
|
||||||
"koa": "^2.13.0",
|
"koa": "^2.13.0",
|
||||||
|
|||||||
@@ -3,6 +3,54 @@
|
|||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||||
|
|
||||||
|
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
|
||||||
|
|
||||||
|
**Note:** Version bump only for package @expo/entity-full-integration-tests
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# [0.23.0](https://github.com/expo/entity/compare/v0.22.0...v0.23.0) (2022-02-09)
|
||||||
|
|
||||||
|
**Note:** Version bump only for package @expo/entity-full-integration-tests
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
|
||||||
|
|
||||||
|
**Note:** Version bump only for package @expo/entity-full-integration-tests
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# [0.21.0](https://github.com/expo/entity/compare/v0.20.0...v0.21.0) (2022-01-03)
|
||||||
|
|
||||||
|
**Note:** Version bump only for package @expo/entity-full-integration-tests
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# [0.20.0](https://github.com/expo/entity/compare/v0.19.0...v0.20.0) (2021-12-29)
|
||||||
|
|
||||||
|
**Note:** Version bump only for package @expo/entity-full-integration-tests
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# [0.19.0](https://github.com/expo/entity/compare/v0.18.0...v0.19.0) (2021-10-28)
|
||||||
|
|
||||||
|
**Note:** Version bump only for package @expo/entity-full-integration-tests
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# [0.18.0](https://github.com/expo/entity/compare/v0.17.0...v0.18.0) (2021-10-14)
|
# [0.18.0](https://github.com/expo/entity/compare/v0.17.0...v0.18.0) (2021-10-14)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@expo/entity-full-integration-tests",
|
"name": "@expo/entity-full-integration-tests",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.18.0",
|
"version": "0.24.0",
|
||||||
"description": "Full redis and knex integration tests for the entity framework",
|
"description": "Full redis and knex integration tests for the entity framework",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"tsc": "tsc",
|
"tsc": "tsc",
|
||||||
@@ -27,8 +27,8 @@
|
|||||||
"@expo/entity-database-adapter-knex": "*"
|
"@expo/entity-database-adapter-knex": "*"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@expo/entity": "^0.18.0",
|
"@expo/entity": "^0.24.0",
|
||||||
"@expo/entity-cache-adapter-redis": "^0.18.0",
|
"@expo/entity-cache-adapter-redis": "^0.24.0",
|
||||||
"@expo/entity-database-adapter-knex": "^0.18.0"
|
"@expo/entity-database-adapter-knex": "^0.24.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Change Log
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||||
|
|
||||||
|
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
|
||||||
|
|
||||||
|
**Note:** Version bump only for package @expo/entity-ip-address-field
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# [0.23.0](https://github.com/expo/entity/compare/v0.22.0...v0.23.0) (2022-02-09)
|
||||||
|
|
||||||
|
**Note:** Version bump only for package @expo/entity-ip-address-field
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
|
||||||
|
|
||||||
|
**Note:** Version bump only for package @expo/entity-ip-address-field
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# [0.21.0](https://github.com/expo/entity/compare/v0.20.0...v0.21.0) (2022-01-03)
|
||||||
|
|
||||||
|
**Note:** Version bump only for package @expo/entity-ip-address-field
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# [0.20.0](https://github.com/expo/entity/compare/v0.19.0...v0.20.0) (2021-12-29)
|
||||||
|
|
||||||
|
**Note:** Version bump only for package @expo/entity-ip-address-field
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# [0.19.0](https://github.com/expo/entity/compare/v0.18.0...v0.19.0) (2021-10-28)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add IP support ([#138](https://github.com/expo/entity/issues/138)) ([1405a91](https://github.com/expo/entity/commit/1405a9126ee3a56c3e2a38bf3b697283744f5b5d))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# [0.18.0](https://github.com/expo/entity/compare/v0.17.0...v0.18.0) (2021-10-14)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* lands @expo/entity-ip-address-field ([#138](https://github.com/expo/entity/pull/138))
|
||||||
|
* enable noImplicitOverride tsc setting ([#135](https://github.com/expo/entity/issues/135)) ([4263cb9](https://github.com/expo/entity/commit/4263cb9b8b69fe4fb68c74ec1c7aba04508a1555))
|
||||||
|
* expose describeFieldTestCase for use in other packages ([#139](https://github.com/expo/entity/issues/139)) ([1220477](https://github.com/expo/entity/commit/1220477aa331349b482ad015b44f76fa2bda7b90))
|
||||||
|
* upgrade TypeScript to 4.4 ([#134](https://github.com/expo/entity/issues/134)) ([7612392](https://github.com/expo/entity/commit/7612392dbfd9778d25c465c1626c372d75a6d05a))
|
||||||
|
* use better QueryContext types for transactional triggers and validators ([#136](https://github.com/expo/entity/issues/136)) ([5c7efe1](https://github.com/expo/entity/commit/5c7efe1678424ff907687e9327175bdc2538c3e0))
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# @expo/entity-ip-address-field
|
||||||
|
|
||||||
|
A package containing the IP address EntityField definitions for @expo/entity. Supports addresses with either IPv4 or IPv6 syntax.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "@expo/entity-ip-address-field",
|
||||||
|
"version": "0.24.0",
|
||||||
|
"description": "IP address EntityField definitions for @expo/entity",
|
||||||
|
"files": [
|
||||||
|
"build",
|
||||||
|
"src"
|
||||||
|
],
|
||||||
|
"main": "build/index.js",
|
||||||
|
"types": "build/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"tsc": "tsc",
|
||||||
|
"clean": "rm -rf build coverage coverage-integration",
|
||||||
|
"lint": "eslint src",
|
||||||
|
"lint-fix": "eslint src --fix",
|
||||||
|
"test": "jest --rootDir . --config ../../resources/jest.config.js",
|
||||||
|
"barrelsby": "barrelsby --directory src --location top --exclude tests__ --singleQuotes --delete"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"entity"
|
||||||
|
],
|
||||||
|
"author": "Expo",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ip-address": "^8.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@expo/entity": "^0.24.0",
|
||||||
|
"@types/jsbn": "^1.2.29"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@expo/entity": "*"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { EntityFieldDefinition } from '@expo/entity';
|
||||||
|
import { Address4, Address6 } from 'ip-address';
|
||||||
|
|
||||||
|
export class IPAddressField extends EntityFieldDefinition<string> {
|
||||||
|
protected validateInputValueInternal(value: string): boolean {
|
||||||
|
return Address4.isValid(value) || Address6.isValid(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { describeFieldTestCase } from '@expo/entity';
|
||||||
|
|
||||||
|
import { IPAddressField } from '../EntityFields';
|
||||||
|
|
||||||
|
describeFieldTestCase(
|
||||||
|
new IPAddressField({ columnName: 'wat' }),
|
||||||
|
[
|
||||||
|
'192.168.1.1',
|
||||||
|
'198.24.10.0/24',
|
||||||
|
'10.0.0.0',
|
||||||
|
'0:1:2:3:4:5:6:7',
|
||||||
|
'1:2:3:4:5:6:7:8/64',
|
||||||
|
'fedc:ba98:7654:3210:fedc:ba98:7654:3210',
|
||||||
|
],
|
||||||
|
['', 'abs', '0.0.0.x/0', '123.21.23', '1:2:3:4:5:6', '198.10/8']
|
||||||
|
);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/* eslint-disable tsdoc/syntax */
|
||||||
|
/**
|
||||||
|
* @packageDocumentation
|
||||||
|
* @module @expo/entity-ip-address-field
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './EntityFields';
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "build",
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -3,6 +3,56 @@
|
|||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||||
|
|
||||||
|
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
|
||||||
|
|
||||||
|
### Chores
|
||||||
|
|
||||||
|
* Refactor cachers to use an abstract class and interface ([#157](https://github.com/expo/entity/pull/157))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# [0.23.0](https://github.com/expo/entity/compare/v0.22.0...v0.23.0) (2022-02-09)
|
||||||
|
|
||||||
|
### Chores
|
||||||
|
|
||||||
|
* Move data transforming responsibilities to the CacheAdapter instead of the RedisSecondaryEntityCache. ([#153](https://github.com/expo/entity/pull/153))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
|
||||||
|
|
||||||
|
**Note:** Version bump only for package @expo/entity-secondary-cache-redis
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# [0.21.0](https://github.com/expo/entity/compare/v0.20.0...v0.21.0) (2022-01-03)
|
||||||
|
|
||||||
|
**Note:** Version bump only for package @expo/entity-secondary-cache-redis
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# [0.20.0](https://github.com/expo/entity/compare/v0.19.0...v0.20.0) (2021-12-29)
|
||||||
|
|
||||||
|
**Note:** Version bump only for package @expo/entity-secondary-cache-redis
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# [0.19.0](https://github.com/expo/entity/compare/v0.18.0...v0.19.0) (2021-10-28)
|
||||||
|
|
||||||
|
**Note:** Version bump only for package @expo/entity-secondary-cache-redis
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# [0.18.0](https://github.com/expo/entity/compare/v0.17.0...v0.18.0) (2021-10-14)
|
# [0.18.0](https://github.com/expo/entity/compare/v0.17.0...v0.18.0) (2021-10-14)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@expo/entity-secondary-cache-redis",
|
"name": "@expo/entity-secondary-cache-redis",
|
||||||
"version": "0.18.0",
|
"version": "0.24.0",
|
||||||
"description": "Redis secondary cache for @expo/entity",
|
"description": "Redis secondary cache for @expo/entity",
|
||||||
"files": [
|
"files": [
|
||||||
"build",
|
"build",
|
||||||
@@ -34,8 +34,8 @@
|
|||||||
"ioredis": "^4.17.3"
|
"ioredis": "^4.17.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@expo/entity": "^0.18.0",
|
"@expo/entity": "^0.24.0",
|
||||||
"@expo/entity-cache-adapter-redis": "^0.18.0",
|
"@expo/entity-cache-adapter-redis": "^0.24.0",
|
||||||
"nullthrows": "^1.1.1"
|
"nullthrows": "^1.1.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,136 +1,20 @@
|
|||||||
import {
|
import { EntityConfiguration, GenericSecondaryEntityCache } from '@expo/entity';
|
||||||
CacheStatus,
|
import { GenericRedisCacheContext, GenericRedisCacher } from '@expo/entity-cache-adapter-redis';
|
||||||
EntityConfiguration,
|
|
||||||
filterMap,
|
|
||||||
ISecondaryEntityCache,
|
|
||||||
transformCacheObjectToFields,
|
|
||||||
transformFieldsToCacheObject,
|
|
||||||
zipToMap,
|
|
||||||
} from '@expo/entity';
|
|
||||||
import {
|
|
||||||
GenericRedisCacheContext,
|
|
||||||
GenericRedisCacher,
|
|
||||||
redisTransformerMap,
|
|
||||||
} from '@expo/entity-cache-adapter-redis';
|
|
||||||
import invariant from 'invariant';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A custom secondary read-through entity cache is a way to add a custom second layer of caching for a particular
|
* A custom secondary read-through entity cache is a way to add a custom second layer of caching for a particular
|
||||||
* single entity load. One common way this may be used is to add a second layer of caching in a hot path that makes
|
* single entity load. One common way this may be used is to add a second layer of caching in a hot path that makes
|
||||||
* a call to {@link EntityLoader.loadManyByFieldEqualityConjunctionAsync} is guaranteed to return at most one entity.
|
* a call to {@link EntityLoader.loadManyByFieldEqualityConjunctionAsync} is guaranteed to return at most one entity.
|
||||||
*/
|
*/
|
||||||
export default class RedisSecondaryEntityCache<TFields, TLoadParams>
|
export default class RedisSecondaryEntityCache<
|
||||||
implements ISecondaryEntityCache<TFields, TLoadParams>
|
TFields,
|
||||||
{
|
TLoadParams
|
||||||
private readonly genericRedisCacher: GenericRedisCacher;
|
> extends GenericSecondaryEntityCache<TFields, TLoadParams> {
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly entityConfiguration: EntityConfiguration<TFields>,
|
entityConfiguration: EntityConfiguration<TFields>,
|
||||||
genericRedisCacheContext: GenericRedisCacheContext,
|
genericRedisCacheContext: GenericRedisCacheContext,
|
||||||
private readonly constructRedisKey: (params: Readonly<TLoadParams>) => string
|
constructRedisKey: (params: Readonly<TLoadParams>) => string
|
||||||
) {
|
) {
|
||||||
this.genericRedisCacher = new GenericRedisCacher(genericRedisCacheContext);
|
super(new GenericRedisCacher(genericRedisCacheContext, entityConfiguration), constructRedisKey);
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read-through cache function. Steps:
|
|
||||||
*
|
|
||||||
* 1. Check for cached objects
|
|
||||||
* 2. Query the fetcher for loadParams not in the cache
|
|
||||||
* 3. Cache the results from the fetcher
|
|
||||||
* 4. Negatively cache anything missing from the fetcher
|
|
||||||
* 5. Return the full set of data for the query.
|
|
||||||
*
|
|
||||||
* @param loadParamsArray - array of loadParams to load from the cache
|
|
||||||
* @param fetcher - closure used to provide underlying data source objects for loadParams
|
|
||||||
* @returns map from loadParams to the entity field object
|
|
||||||
*/
|
|
||||||
public async loadManyThroughAsync(
|
|
||||||
loadParamsArray: readonly Readonly<TLoadParams>[],
|
|
||||||
fetcher: (
|
|
||||||
fetcherLoadParamsArray: readonly Readonly<TLoadParams>[]
|
|
||||||
) => Promise<ReadonlyMap<Readonly<TLoadParams>, Readonly<TFields> | null>>
|
|
||||||
): Promise<ReadonlyMap<Readonly<TLoadParams>, Readonly<TFields> | null>> {
|
|
||||||
const redisKeys = loadParamsArray.map(this.constructRedisKey);
|
|
||||||
const redisKeyToLoadParamsMap = zipToMap(redisKeys, loadParamsArray);
|
|
||||||
|
|
||||||
const cacheLoadResults = await this.genericRedisCacher.loadManyAsync(redisKeys);
|
|
||||||
|
|
||||||
invariant(
|
|
||||||
cacheLoadResults.size === loadParamsArray.length,
|
|
||||||
`${this.constructor.name} loadMany should return a result for each key`
|
|
||||||
);
|
|
||||||
|
|
||||||
const redisKeysToFetch = Array.from(
|
|
||||||
filterMap(
|
|
||||||
cacheLoadResults,
|
|
||||||
(cacheLoadResult) => cacheLoadResult.status === CacheStatus.MISS
|
|
||||||
).keys()
|
|
||||||
);
|
|
||||||
|
|
||||||
// put transformed cache hits in result map
|
|
||||||
const results: Map<Readonly<TLoadParams>, Readonly<TFields> | null> = new Map();
|
|
||||||
cacheLoadResults.forEach((cacheLoadResult, redisKey) => {
|
|
||||||
if (cacheLoadResult.status === CacheStatus.HIT) {
|
|
||||||
const loadParams = redisKeyToLoadParamsMap.get(redisKey);
|
|
||||||
invariant(loadParams !== undefined, 'load params should be in redis key map');
|
|
||||||
results.set(
|
|
||||||
loadParams,
|
|
||||||
transformCacheObjectToFields(
|
|
||||||
this.entityConfiguration,
|
|
||||||
redisTransformerMap,
|
|
||||||
cacheLoadResult.item
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// fetch any misses from DB, add DB objects to results, cache DB results, inform cache of any missing DB results
|
|
||||||
if (redisKeysToFetch.length > 0) {
|
|
||||||
const loadParamsToFetch = redisKeysToFetch.map((redisKey) => {
|
|
||||||
const loadParams = redisKeyToLoadParamsMap.get(redisKey);
|
|
||||||
invariant(loadParams !== undefined, 'load params should be in redis key map');
|
|
||||||
return loadParams;
|
|
||||||
});
|
|
||||||
const fetchResults = await fetcher(loadParamsToFetch);
|
|
||||||
|
|
||||||
const fetchMisses = loadParamsToFetch.filter((loadParams) => {
|
|
||||||
// all values of fetchResults should be field objects or undefined
|
|
||||||
return !fetchResults.get(loadParams);
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const fetchMiss of fetchMisses) {
|
|
||||||
results.set(fetchMiss, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
const objectsToCache: Map<string, object> = new Map();
|
|
||||||
for (const [loadParams, object] of fetchResults.entries()) {
|
|
||||||
if (object) {
|
|
||||||
objectsToCache.set(
|
|
||||||
this.constructRedisKey(loadParams),
|
|
||||||
transformFieldsToCacheObject(this.entityConfiguration, redisTransformerMap, object)
|
|
||||||
);
|
|
||||||
results.set(loadParams, object);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all([
|
|
||||||
this.genericRedisCacher.cacheManyAsync(objectsToCache),
|
|
||||||
this.genericRedisCacher.cacheDBMissesAsync(fetchMisses.map(this.constructRedisKey)),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Invalidate the cache for objects cached by constructRedisKey(loadParams).
|
|
||||||
*
|
|
||||||
* @param loadParamsArray - load params to invalidate
|
|
||||||
*/
|
|
||||||
public async invalidateManyAsync(
|
|
||||||
loadParamsArray: readonly Readonly<TLoadParams>[]
|
|
||||||
): Promise<void> {
|
|
||||||
await this.genericRedisCacher.invalidateManyAsync(loadParamsArray.map(this.constructRedisKey));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,73 @@
|
|||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||||
|
|
||||||
|
# [0.24.0](https://github.com/expo/entity/compare/v0.23.0...v0.24.0) (2022-02-16)
|
||||||
|
|
||||||
|
**Note:** Version bump only for package @expo/entity
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Chores
|
||||||
|
|
||||||
|
* Refactor cachers to use an abstract class and interface ([#157](https://github.com/expo/entity/pull/157))
|
||||||
|
|
||||||
|
# [0.23.0](https://github.com/expo/entity/compare/v0.22.0...v0.23.0) (2022-02-09)
|
||||||
|
|
||||||
|
### Breaking changes
|
||||||
|
|
||||||
|
* Move data transforming responsibilities to the CacheAdapter instead of the ReadThroughEntityCache. Affects any classes extending the EntityCacheAdapter and relying on the transformation behavior of the ReadThroughEntityCache. ([#153](https://github.com/expo/entity/pull/153))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# [0.22.0](https://github.com/expo/entity/compare/v0.21.0...v0.22.0) (2022-02-04)
|
||||||
|
|
||||||
|
**Note:** Version bump only for package @expo/entity
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# [0.21.0](https://github.com/expo/entity/compare/v0.20.0...v0.21.0) (2022-01-03)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add pre-commit callbacks on EntityQueryContext ([#147](https://github.com/expo/entity/issues/147)) ([f1d9847](https://github.com/expo/entity/commit/f1d9847210f5775de964509e44ce493e44482b21))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# [0.20.0](https://github.com/expo/entity/compare/v0.19.0...v0.20.0) (2021-12-29)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* separate post-commit invalidation callbacks from trigger and user callbacks ([#144](https://github.com/expo/entity/issues/144)) ([efa131e](https://github.com/expo/entity/commit/efa131ef29532accf0e51b6d867ffe15a27b4c90))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add cascading deletion info ([#145](https://github.com/expo/entity/issues/145)) ([3727191](https://github.com/expo/entity/commit/372719176742d3ead6b7cbca29cb66b09b3fe09c))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# [0.19.0](https://github.com/expo/entity/compare/v0.18.0...v0.19.0) (2021-10-28)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add authorization metrics ([#142](https://github.com/expo/entity/issues/142)) ([7cf54d8](https://github.com/expo/entity/commit/7cf54d83c52e104159a3e2e675cdda2d29afe204))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# [0.18.0](https://github.com/expo/entity/compare/v0.17.0...v0.18.0) (2021-10-14)
|
# [0.18.0](https://github.com/expo/entity/compare/v0.17.0...v0.18.0) (2021-10-14)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@expo/entity",
|
"name": "@expo/entity",
|
||||||
"version": "0.18.0",
|
"version": "0.24.0",
|
||||||
"description": "A privacy-first data model",
|
"description": "A privacy-first data model",
|
||||||
"files": [
|
"files": [
|
||||||
"build",
|
"build",
|
||||||
|
|||||||
@@ -243,12 +243,16 @@ export default abstract class Entity<
|
|||||||
.getQueryContextProvider()
|
.getQueryContextProvider()
|
||||||
.getQueryContext()
|
.getQueryContext()
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
|
const companion = existingEntity
|
||||||
|
.getViewerContext()
|
||||||
|
.getViewerScopedEntityCompanionForClass(this);
|
||||||
const privacyPolicy = new (this.getCompanionDefinition().privacyPolicyClass)();
|
const privacyPolicy = new (this.getCompanionDefinition().privacyPolicyClass)();
|
||||||
const evaluationResult = await asyncResult(
|
const evaluationResult = await asyncResult(
|
||||||
privacyPolicy.authorizeUpdateAsync(
|
privacyPolicy.authorizeUpdateAsync(
|
||||||
existingEntity.getViewerContext(),
|
existingEntity.getViewerContext(),
|
||||||
queryContext,
|
queryContext,
|
||||||
existingEntity
|
existingEntity,
|
||||||
|
companion.getMetricsAdapter()
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
return evaluationResult.ok;
|
return evaluationResult.ok;
|
||||||
@@ -292,12 +296,16 @@ export default abstract class Entity<
|
|||||||
.getQueryContextProvider()
|
.getQueryContextProvider()
|
||||||
.getQueryContext()
|
.getQueryContext()
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
|
const companion = existingEntity
|
||||||
|
.getViewerContext()
|
||||||
|
.getViewerScopedEntityCompanionForClass(this);
|
||||||
const privacyPolicy = new (this.getCompanionDefinition().privacyPolicyClass)();
|
const privacyPolicy = new (this.getCompanionDefinition().privacyPolicyClass)();
|
||||||
const evaluationResult = await asyncResult(
|
const evaluationResult = await asyncResult(
|
||||||
privacyPolicy.authorizeDeleteAsync(
|
privacyPolicy.authorizeDeleteAsync(
|
||||||
existingEntity.getViewerContext(),
|
existingEntity.getViewerContext(),
|
||||||
queryContext,
|
queryContext,
|
||||||
existingEntity
|
existingEntity,
|
||||||
|
companion.getMetricsAdapter()
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
return evaluationResult.ok;
|
return evaluationResult.ok;
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import EntityConfiguration from './EntityConfiguration';
|
import EntityConfiguration from './EntityConfiguration';
|
||||||
import { FieldTransformerMap } from './internal/EntityFieldTransformationUtils';
|
|
||||||
import { CacheLoadResult } from './internal/ReadThroughEntityCache';
|
import { CacheLoadResult } from './internal/ReadThroughEntityCache';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -9,13 +8,6 @@ import { CacheLoadResult } from './internal/ReadThroughEntityCache';
|
|||||||
export default abstract class EntityCacheAdapter<TFields> {
|
export default abstract class EntityCacheAdapter<TFields> {
|
||||||
constructor(protected readonly entityConfiguration: EntityConfiguration<TFields>) {}
|
constructor(protected readonly entityConfiguration: EntityConfiguration<TFields>) {}
|
||||||
|
|
||||||
/**
|
|
||||||
* Transformer definitions for field types. Used to modify values as they are read from or written to
|
|
||||||
* the cache. Override in concrete subclasses to change transformation behavior.
|
|
||||||
* If a field type is not present in the map, then fields of that type will not be transformed.
|
|
||||||
*/
|
|
||||||
public abstract getFieldTransformerMap(): FieldTransformerMap;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load many objects from cache.
|
* Load many objects from cache.
|
||||||
* @param fieldName - object field being queried
|
* @param fieldName - object field being queried
|
||||||
@@ -25,7 +17,7 @@ export default abstract class EntityCacheAdapter<TFields> {
|
|||||||
public abstract loadManyAsync<N extends keyof TFields>(
|
public abstract loadManyAsync<N extends keyof TFields>(
|
||||||
fieldName: N,
|
fieldName: N,
|
||||||
fieldValues: readonly NonNullable<TFields[N]>[]
|
fieldValues: readonly NonNullable<TFields[N]>[]
|
||||||
): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult>>;
|
): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult<TFields>>>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cache many objects fetched from the DB.
|
* Cache many objects fetched from the DB.
|
||||||
@@ -34,7 +26,7 @@ export default abstract class EntityCacheAdapter<TFields> {
|
|||||||
*/
|
*/
|
||||||
public abstract cacheManyAsync<N extends keyof TFields>(
|
public abstract cacheManyAsync<N extends keyof TFields>(
|
||||||
fieldName: N,
|
fieldName: N,
|
||||||
objectMap: ReadonlyMap<NonNullable<TFields[N]>, object>
|
objectMap: ReadonlyMap<NonNullable<TFields[N]>, Readonly<TFields>>
|
||||||
): Promise<void>;
|
): Promise<void>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export default class EntityCompanion<
|
|||||||
TEntity,
|
TEntity,
|
||||||
TSelectedFields
|
TSelectedFields
|
||||||
>,
|
>,
|
||||||
metricsAdapter: IEntityMetricsAdapter
|
private readonly metricsAdapter: IEntityMetricsAdapter
|
||||||
) {
|
) {
|
||||||
const privacyPolicy = new PrivacyPolicyClass();
|
const privacyPolicy = new PrivacyPolicyClass();
|
||||||
this.entityLoaderFactory = new EntityLoaderFactory<
|
this.entityLoaderFactory = new EntityLoaderFactory<
|
||||||
@@ -87,7 +87,8 @@ export default class EntityCompanion<
|
|||||||
tableDataCoordinator.entityConfiguration,
|
tableDataCoordinator.entityConfiguration,
|
||||||
entityClass,
|
entityClass,
|
||||||
privacyPolicy,
|
privacyPolicy,
|
||||||
tableDataCoordinator.dataManager
|
tableDataCoordinator.dataManager,
|
||||||
|
metricsAdapter
|
||||||
);
|
);
|
||||||
this.entityMutatorFactory = new EntityMutatorFactory(
|
this.entityMutatorFactory = new EntityMutatorFactory(
|
||||||
tableDataCoordinator.entityConfiguration,
|
tableDataCoordinator.entityConfiguration,
|
||||||
@@ -129,4 +130,11 @@ export default class EntityCompanion<
|
|||||||
getQueryContextProvider(): EntityQueryContextProvider {
|
getQueryContextProvider(): EntityQueryContextProvider {
|
||||||
return this.tableDataCoordinator.getQueryContextProvider();
|
return this.tableDataCoordinator.getQueryContextProvider();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the {@link IEntityMetricsAdapter} for this companion.
|
||||||
|
*/
|
||||||
|
getMetricsAdapter(): IEntityMetricsAdapter {
|
||||||
|
return this.metricsAdapter;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -174,7 +174,7 @@ export default class EntityCompanionProvider {
|
|||||||
* @param cacheAdapterFlavors - Cache adapter configurations for this instance
|
* @param cacheAdapterFlavors - Cache adapter configurations for this instance
|
||||||
*/
|
*/
|
||||||
constructor(
|
constructor(
|
||||||
private metricsAdapter: IEntityMetricsAdapter,
|
public readonly metricsAdapter: IEntityMetricsAdapter,
|
||||||
private databaseAdapterFlavors: ReadonlyMap<
|
private databaseAdapterFlavors: ReadonlyMap<
|
||||||
DatabaseAdapterFlavor,
|
DatabaseAdapterFlavor,
|
||||||
DatabaseAdapterFlavorDefinition
|
DatabaseAdapterFlavorDefinition
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ export enum EntityEdgeDeletionBehavior {
|
|||||||
SET_NULL,
|
SET_NULL,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines an association between entities. An association is primarily used to define cascading deletion behavior.
|
||||||
|
*/
|
||||||
export interface EntityAssociationDefinition<
|
export interface EntityAssociationDefinition<
|
||||||
TViewerContext extends ViewerContext,
|
TViewerContext extends ViewerContext,
|
||||||
TAssociatedFields,
|
TAssociatedFields,
|
||||||
@@ -83,6 +86,10 @@ export interface EntityAssociationDefinition<
|
|||||||
edgeDeletionBehavior?: EntityEdgeDeletionBehavior;
|
edgeDeletionBehavior?: EntityEdgeDeletionBehavior;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Definition for a field referencing a column in the underlying database. Specifies things like
|
||||||
|
* cache behavior and associations, and handles input validation.
|
||||||
|
*/
|
||||||
export abstract class EntityFieldDefinition<T> {
|
export abstract class EntityFieldDefinition<T> {
|
||||||
readonly columnName: string;
|
readonly columnName: string;
|
||||||
readonly cache: boolean;
|
readonly cache: boolean;
|
||||||
@@ -93,6 +100,7 @@ export abstract class EntityFieldDefinition<T> {
|
|||||||
* @param cache - Whether or not to cache loaded instances of the entity by this field. The column name is
|
* @param cache - Whether or not to cache loaded instances of the entity by this field. The column name is
|
||||||
* used to derive a cache key for the cache entry. If true, this column must be able uniquely
|
* used to derive a cache key for the cache entry. If true, this column must be able uniquely
|
||||||
* identify the entity.
|
* identify the entity.
|
||||||
|
* @param association - Defines the association behavior for an entity that this column references.
|
||||||
*/
|
*/
|
||||||
constructor({
|
constructor({
|
||||||
columnName,
|
columnName,
|
||||||
|
|||||||
@@ -2,59 +2,104 @@ import { validate as validateUUID } from 'uuid';
|
|||||||
|
|
||||||
import { EntityFieldDefinition } from './EntityFieldDefinition';
|
import { EntityFieldDefinition } from './EntityFieldDefinition';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link EntityFieldDefinition} for a column with a JS string type.
|
||||||
|
*/
|
||||||
export class StringField extends EntityFieldDefinition<string> {
|
export class StringField extends EntityFieldDefinition<string> {
|
||||||
protected validateInputValueInternal(value: string): boolean {
|
protected validateInputValueInternal(value: string): boolean {
|
||||||
return typeof value === 'string';
|
return typeof value === 'string';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link EntityFieldDefinition} for a column with a JS string type.
|
||||||
|
* Enforces that the string is a valid UUID.
|
||||||
|
*/
|
||||||
export class UUIDField extends StringField {
|
export class UUIDField extends StringField {
|
||||||
protected override validateInputValueInternal(value: string): boolean {
|
protected override validateInputValueInternal(value: string): boolean {
|
||||||
return validateUUID(value);
|
return validateUUID(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link EntityFieldDefinition} for a column with a JS Date type.
|
||||||
|
*/
|
||||||
export class DateField extends EntityFieldDefinition<Date> {
|
export class DateField extends EntityFieldDefinition<Date> {
|
||||||
protected validateInputValueInternal(value: Date): boolean {
|
protected validateInputValueInternal(value: Date): boolean {
|
||||||
return value instanceof Date;
|
return value instanceof Date;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link EntityFieldDefinition} for a column with a JS boolean type.
|
||||||
|
*/
|
||||||
export class BooleanField extends EntityFieldDefinition<boolean> {
|
export class BooleanField extends EntityFieldDefinition<boolean> {
|
||||||
protected validateInputValueInternal(value: boolean): boolean {
|
protected validateInputValueInternal(value: boolean): boolean {
|
||||||
return typeof value === 'boolean';
|
return typeof value === 'boolean';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link EntityFieldDefinition} for a column with a JS number type.
|
||||||
|
* Enforces that the number is an integer.
|
||||||
|
*/
|
||||||
export class IntField extends EntityFieldDefinition<number> {
|
export class IntField extends EntityFieldDefinition<number> {
|
||||||
protected validateInputValueInternal(value: number): boolean {
|
protected validateInputValueInternal(value: number): boolean {
|
||||||
return typeof value === 'number' && Number.isInteger(value);
|
return typeof value === 'number' && Number.isInteger(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link EntityFieldDefinition} for a column with a JS number type.
|
||||||
|
* Enforces that the number is a float (which includes integers in JS).
|
||||||
|
*/
|
||||||
export class FloatField extends EntityFieldDefinition<number> {
|
export class FloatField extends EntityFieldDefinition<number> {
|
||||||
protected validateInputValueInternal(value: number): boolean {
|
protected validateInputValueInternal(value: number): boolean {
|
||||||
return typeof value === 'number';
|
return typeof value === 'number';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link EntityFieldDefinition} for a column with a JS string array type.
|
||||||
|
* Enforces that every member of the string array is a string.
|
||||||
|
*/
|
||||||
export class StringArrayField extends EntityFieldDefinition<string[]> {
|
export class StringArrayField extends EntityFieldDefinition<string[]> {
|
||||||
protected validateInputValueInternal(value: string[]): boolean {
|
protected validateInputValueInternal(value: string[]): boolean {
|
||||||
return Array.isArray(value) && value.every((subValue) => typeof subValue === 'string');
|
return Array.isArray(value) && value.every((subValue) => typeof subValue === 'string');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link EntityFieldDefinition} for a column with a JS JSON object type.
|
||||||
|
*/
|
||||||
export class JSONObjectField extends EntityFieldDefinition<object> {
|
export class JSONObjectField extends EntityFieldDefinition<object> {
|
||||||
protected validateInputValueInternal(value: object): boolean {
|
protected validateInputValueInternal(value: object): boolean {
|
||||||
return typeof value === 'object' && !Array.isArray(value);
|
return typeof value === 'object' && !Array.isArray(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link EntityFieldDefinition} for a enum column with a JS string or number type.
|
||||||
|
*/
|
||||||
export class EnumField extends EntityFieldDefinition<string | number> {
|
export class EnumField extends EntityFieldDefinition<string | number> {
|
||||||
protected validateInputValueInternal(value: string | number): boolean {
|
protected validateInputValueInternal(value: string | number): boolean {
|
||||||
return typeof value === 'number' || typeof value === 'string';
|
return typeof value === 'number' || typeof value === 'string';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link EntityFieldDefinition} for a column with a JS JSON array type.
|
||||||
|
*/
|
||||||
export class JSONArrayField extends EntityFieldDefinition<any[]> {
|
export class JSONArrayField extends EntityFieldDefinition<any[]> {
|
||||||
protected validateInputValueInternal(value: any[]): boolean {
|
protected validateInputValueInternal(value: any[]): boolean {
|
||||||
return Array.isArray(value);
|
return Array.isArray(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link EntityFieldDefinition} for a column that may be a JS JSON array type.
|
||||||
|
* Does not do any validation.
|
||||||
|
*/
|
||||||
export class MaybeJSONArrayField extends EntityFieldDefinition<any | any[]> {
|
export class MaybeJSONArrayField extends EntityFieldDefinition<any | any[]> {
|
||||||
protected validateInputValueInternal(_value: any): boolean {
|
protected validateInputValueInternal(_value: any): boolean {
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import ViewerContext from './ViewerContext';
|
|||||||
import EntityInvalidFieldValueError from './errors/EntityInvalidFieldValueError';
|
import EntityInvalidFieldValueError from './errors/EntityInvalidFieldValueError';
|
||||||
import EntityNotFoundError from './errors/EntityNotFoundError';
|
import EntityNotFoundError from './errors/EntityNotFoundError';
|
||||||
import EntityDataManager from './internal/EntityDataManager';
|
import EntityDataManager from './internal/EntityDataManager';
|
||||||
|
import IEntityMetricsAdapter from './metrics/IEntityMetricsAdapter';
|
||||||
import { mapMap, mapMapAsync } from './utils/collections/maps';
|
import { mapMap, mapMapAsync } from './utils/collections/maps';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -49,7 +50,8 @@ export default class EntityLoader<
|
|||||||
TSelectedFields
|
TSelectedFields
|
||||||
>,
|
>,
|
||||||
private readonly privacyPolicy: TPrivacyPolicy,
|
private readonly privacyPolicy: TPrivacyPolicy,
|
||||||
private readonly dataManager: EntityDataManager<TFields>
|
private readonly dataManager: EntityDataManager<TFields>,
|
||||||
|
protected readonly metricsAdapter: IEntityMetricsAdapter
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -216,7 +218,8 @@ export default class EntityLoader<
|
|||||||
this.privacyPolicy.authorizeReadAsync(
|
this.privacyPolicy.authorizeReadAsync(
|
||||||
this.viewerContext,
|
this.viewerContext,
|
||||||
this.queryContext,
|
this.queryContext,
|
||||||
uncheckedEntityResult.value
|
uncheckedEntityResult.value,
|
||||||
|
this.metricsAdapter
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
@@ -269,7 +272,8 @@ export default class EntityLoader<
|
|||||||
this.privacyPolicy.authorizeReadAsync(
|
this.privacyPolicy.authorizeReadAsync(
|
||||||
this.viewerContext,
|
this.viewerContext,
|
||||||
this.queryContext,
|
this.queryContext,
|
||||||
uncheckedEntityResult.value
|
uncheckedEntityResult.value,
|
||||||
|
this.metricsAdapter
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
@@ -328,7 +332,8 @@ export default class EntityLoader<
|
|||||||
this.privacyPolicy.authorizeReadAsync(
|
this.privacyPolicy.authorizeReadAsync(
|
||||||
this.viewerContext,
|
this.viewerContext,
|
||||||
this.queryContext,
|
this.queryContext,
|
||||||
uncheckedEntityResult.value
|
uncheckedEntityResult.value,
|
||||||
|
this.metricsAdapter
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { EntityQueryContext } from './EntityQueryContext';
|
|||||||
import ReadonlyEntity from './ReadonlyEntity';
|
import ReadonlyEntity from './ReadonlyEntity';
|
||||||
import ViewerContext from './ViewerContext';
|
import ViewerContext from './ViewerContext';
|
||||||
import EntityDataManager from './internal/EntityDataManager';
|
import EntityDataManager from './internal/EntityDataManager';
|
||||||
|
import IEntityMetricsAdapter from './metrics/IEntityMetricsAdapter';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The primary entry point for loading entities.
|
* The primary entry point for loading entities.
|
||||||
@@ -35,7 +36,8 @@ export default class EntityLoaderFactory<
|
|||||||
TSelectedFields
|
TSelectedFields
|
||||||
>,
|
>,
|
||||||
private readonly privacyPolicyClass: TPrivacyPolicy,
|
private readonly privacyPolicyClass: TPrivacyPolicy,
|
||||||
private readonly dataManager: EntityDataManager<TFields>
|
private readonly dataManager: EntityDataManager<TFields>,
|
||||||
|
protected readonly metricsAdapter: IEntityMetricsAdapter
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -53,7 +55,8 @@ export default class EntityLoaderFactory<
|
|||||||
this.entityConfiguration,
|
this.entityConfiguration,
|
||||||
this.entityClass,
|
this.entityClass,
|
||||||
this.privacyPolicyClass,
|
this.privacyPolicyClass,
|
||||||
this.dataManager
|
this.dataManager,
|
||||||
|
this.metricsAdapter
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import Entity from './Entity';
|
||||||
|
import ViewerContext from './ViewerContext';
|
||||||
|
|
||||||
|
export enum EntityMutationType {
|
||||||
|
CREATE,
|
||||||
|
UPDATE,
|
||||||
|
DELETE,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EntityValidatorMutationInfo<
|
||||||
|
TFields,
|
||||||
|
TID extends NonNullable<TFields[TSelectedFields]>,
|
||||||
|
TViewerContext extends ViewerContext,
|
||||||
|
TEntity extends Entity<TFields, TID, TViewerContext, TSelectedFields>,
|
||||||
|
TSelectedFields extends keyof TFields = keyof TFields
|
||||||
|
> =
|
||||||
|
| {
|
||||||
|
type: EntityMutationType.CREATE;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: EntityMutationType.UPDATE;
|
||||||
|
previousValue: TEntity;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EntityMutationTriggerDeleteCascadeInfo<> = {
|
||||||
|
entity: Entity<any, any, any, any>;
|
||||||
|
cascadingDeleteCause: EntityMutationTriggerDeleteCascadeInfo | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EntityTriggerMutationInfo<
|
||||||
|
TFields,
|
||||||
|
TID extends NonNullable<TFields[TSelectedFields]>,
|
||||||
|
TViewerContext extends ViewerContext,
|
||||||
|
TEntity extends Entity<TFields, TID, TViewerContext, TSelectedFields>,
|
||||||
|
TSelectedFields extends keyof TFields = keyof TFields
|
||||||
|
> =
|
||||||
|
| {
|
||||||
|
type: EntityMutationType.CREATE;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: EntityMutationType.UPDATE;
|
||||||
|
previousValue: TEntity;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: EntityMutationType.DELETE;
|
||||||
|
cascadingDeleteCause: EntityMutationTriggerDeleteCascadeInfo | null;
|
||||||
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { EntityMutationInfo } from './EntityMutator';
|
import { EntityTriggerMutationInfo } from './EntityMutationInfo';
|
||||||
import { EntityTransactionalQueryContext } from './EntityQueryContext';
|
import { EntityTransactionalQueryContext } from './EntityQueryContext';
|
||||||
import ReadonlyEntity from './ReadonlyEntity';
|
import ReadonlyEntity from './ReadonlyEntity';
|
||||||
import ViewerContext from './ViewerContext';
|
import ViewerContext from './ViewerContext';
|
||||||
@@ -80,7 +80,7 @@ export abstract class EntityMutationTrigger<
|
|||||||
viewerContext: TViewerContext,
|
viewerContext: TViewerContext,
|
||||||
queryContext: EntityTransactionalQueryContext,
|
queryContext: EntityTransactionalQueryContext,
|
||||||
entity: TEntity,
|
entity: TEntity,
|
||||||
mutationInfo: EntityMutationInfo<TFields, TID, TViewerContext, TEntity, TSelectedFields>
|
mutationInfo: EntityTriggerMutationInfo<TFields, TID, TViewerContext, TEntity, TSelectedFields>
|
||||||
): Promise<void>;
|
): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +98,6 @@ export abstract class EntityNonTransactionalMutationTrigger<
|
|||||||
abstract executeAsync(
|
abstract executeAsync(
|
||||||
viewerContext: TViewerContext,
|
viewerContext: TViewerContext,
|
||||||
entity: TEntity,
|
entity: TEntity,
|
||||||
mutationInfo: EntityMutationInfo<TFields, TID, TViewerContext, TEntity, TSelectedFields>
|
mutationInfo: EntityTriggerMutationInfo<TFields, TID, TViewerContext, TEntity, TSelectedFields>
|
||||||
): Promise<void>;
|
): Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { EntityMutationInfo } from './EntityMutator';
|
import { EntityValidatorMutationInfo } from './EntityMutationInfo';
|
||||||
import { EntityTransactionalQueryContext } from './EntityQueryContext';
|
import { EntityTransactionalQueryContext } from './EntityQueryContext';
|
||||||
import ReadonlyEntity from './ReadonlyEntity';
|
import ReadonlyEntity from './ReadonlyEntity';
|
||||||
import ViewerContext from './ViewerContext';
|
import ViewerContext from './ViewerContext';
|
||||||
@@ -18,6 +18,12 @@ export default abstract class EntityMutationValidator<
|
|||||||
viewerContext: TViewerContext,
|
viewerContext: TViewerContext,
|
||||||
queryContext: EntityTransactionalQueryContext,
|
queryContext: EntityTransactionalQueryContext,
|
||||||
entity: TEntity,
|
entity: TEntity,
|
||||||
mutationInfo: EntityMutationInfo<TFields, TID, TViewerContext, TEntity, TSelectedFields>
|
mutationInfo: EntityValidatorMutationInfo<
|
||||||
|
TFields,
|
||||||
|
TID,
|
||||||
|
TViewerContext,
|
||||||
|
TEntity,
|
||||||
|
TSelectedFields
|
||||||
|
>
|
||||||
): Promise<void>;
|
): Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -7,6 +7,12 @@ import EntityConfiguration from './EntityConfiguration';
|
|||||||
import EntityDatabaseAdapter from './EntityDatabaseAdapter';
|
import EntityDatabaseAdapter from './EntityDatabaseAdapter';
|
||||||
import { EntityEdgeDeletionBehavior } from './EntityFieldDefinition';
|
import { EntityEdgeDeletionBehavior } from './EntityFieldDefinition';
|
||||||
import EntityLoaderFactory from './EntityLoaderFactory';
|
import EntityLoaderFactory from './EntityLoaderFactory';
|
||||||
|
import {
|
||||||
|
EntityValidatorMutationInfo,
|
||||||
|
EntityMutationType,
|
||||||
|
EntityTriggerMutationInfo,
|
||||||
|
EntityMutationTriggerDeleteCascadeInfo,
|
||||||
|
} from './EntityMutationInfo';
|
||||||
import EntityMutationTriggerConfiguration, {
|
import EntityMutationTriggerConfiguration, {
|
||||||
EntityMutationTrigger,
|
EntityMutationTrigger,
|
||||||
EntityNonTransactionalMutationTrigger,
|
EntityNonTransactionalMutationTrigger,
|
||||||
@@ -21,30 +27,6 @@ import { timeAndLogMutationEventAsync } from './metrics/EntityMetricsUtils';
|
|||||||
import IEntityMetricsAdapter, { EntityMetricsMutationType } from './metrics/IEntityMetricsAdapter';
|
import IEntityMetricsAdapter, { EntityMetricsMutationType } from './metrics/IEntityMetricsAdapter';
|
||||||
import { mapMapAsync } from './utils/collections/maps';
|
import { mapMapAsync } from './utils/collections/maps';
|
||||||
|
|
||||||
export enum EntityMutationType {
|
|
||||||
CREATE,
|
|
||||||
UPDATE,
|
|
||||||
DELETE,
|
|
||||||
}
|
|
||||||
|
|
||||||
export type EntityMutationInfo<
|
|
||||||
TFields,
|
|
||||||
TID extends NonNullable<TFields[TSelectedFields]>,
|
|
||||||
TViewerContext extends ViewerContext,
|
|
||||||
TEntity extends Entity<TFields, TID, TViewerContext, TSelectedFields>,
|
|
||||||
TSelectedFields extends keyof TFields = keyof TFields
|
|
||||||
> =
|
|
||||||
| {
|
|
||||||
type: EntityMutationType.CREATE;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: EntityMutationType.UPDATE;
|
|
||||||
previousValue: TEntity;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: EntityMutationType.DELETE;
|
|
||||||
};
|
|
||||||
|
|
||||||
abstract class BaseMutator<
|
abstract class BaseMutator<
|
||||||
TFields,
|
TFields,
|
||||||
TID extends NonNullable<TFields[TSelectedFields]>,
|
TID extends NonNullable<TFields[TSelectedFields]>,
|
||||||
@@ -110,26 +92,44 @@ abstract class BaseMutator<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async executeMutationTriggersOrValidatorsAsync(
|
protected async executeMutationValidatorsAsync(
|
||||||
triggersOrValidators:
|
validators: EntityMutationValidator<TFields, TID, TViewerContext, TEntity, TSelectedFields>[],
|
||||||
| EntityMutationTrigger<TFields, TID, TViewerContext, TEntity, TSelectedFields>[]
|
|
||||||
| EntityMutationValidator<TFields, TID, TViewerContext, TEntity, TSelectedFields>[]
|
|
||||||
| undefined,
|
|
||||||
queryContext: EntityTransactionalQueryContext,
|
queryContext: EntityTransactionalQueryContext,
|
||||||
entity: TEntity,
|
entity: TEntity,
|
||||||
mutationInfo: EntityMutationInfo<TFields, TID, TViewerContext, TEntity, TSelectedFields>
|
mutationInfo: EntityValidatorMutationInfo<
|
||||||
|
TFields,
|
||||||
|
TID,
|
||||||
|
TViewerContext,
|
||||||
|
TEntity,
|
||||||
|
TSelectedFields
|
||||||
|
>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!triggersOrValidators) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
triggersOrValidators.map((triggerOrValidator) =>
|
validators.map((validator) =>
|
||||||
triggerOrValidator.executeAsync(this.viewerContext, queryContext, entity, mutationInfo)
|
validator.executeAsync(this.viewerContext, queryContext, entity, mutationInfo)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async executeNonTransactionalMutationTriggersOrValidatorsAsync(
|
protected async executeMutationTriggersAsync(
|
||||||
|
triggers:
|
||||||
|
| EntityMutationTrigger<TFields, TID, TViewerContext, TEntity, TSelectedFields>[]
|
||||||
|
| undefined,
|
||||||
|
queryContext: EntityTransactionalQueryContext,
|
||||||
|
entity: TEntity,
|
||||||
|
mutationInfo: EntityTriggerMutationInfo<TFields, TID, TViewerContext, TEntity, TSelectedFields>
|
||||||
|
): Promise<void> {
|
||||||
|
if (!triggers) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await Promise.all(
|
||||||
|
triggers.map((trigger) =>
|
||||||
|
trigger.executeAsync(this.viewerContext, queryContext, entity, mutationInfo)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async executeNonTransactionalMutationTriggersAsync(
|
||||||
triggers:
|
triggers:
|
||||||
| EntityNonTransactionalMutationTrigger<
|
| EntityNonTransactionalMutationTrigger<
|
||||||
TFields,
|
TFields,
|
||||||
@@ -140,7 +140,7 @@ abstract class BaseMutator<
|
|||||||
>[]
|
>[]
|
||||||
| undefined,
|
| undefined,
|
||||||
entity: TEntity,
|
entity: TEntity,
|
||||||
mutationInfo: EntityMutationInfo<TFields, TID, TViewerContext, TEntity, TSelectedFields>
|
mutationInfo: EntityTriggerMutationInfo<TFields, TID, TViewerContext, TEntity, TSelectedFields>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!triggers) {
|
if (!triggers) {
|
||||||
return;
|
return;
|
||||||
@@ -220,26 +220,27 @@ export class CreateMutator<
|
|||||||
this.privacyPolicy.authorizeCreateAsync(
|
this.privacyPolicy.authorizeCreateAsync(
|
||||||
this.viewerContext,
|
this.viewerContext,
|
||||||
queryContext,
|
queryContext,
|
||||||
temporaryEntityForPrivacyCheck
|
temporaryEntityForPrivacyCheck,
|
||||||
|
this.metricsAdapter
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
if (!authorizeCreateResult.ok) {
|
if (!authorizeCreateResult.ok) {
|
||||||
return authorizeCreateResult;
|
return authorizeCreateResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.executeMutationTriggersOrValidatorsAsync(
|
await this.executeMutationValidatorsAsync(
|
||||||
this.mutationValidators,
|
this.mutationValidators,
|
||||||
queryContext,
|
queryContext,
|
||||||
temporaryEntityForPrivacyCheck,
|
temporaryEntityForPrivacyCheck,
|
||||||
{ type: EntityMutationType.CREATE }
|
{ type: EntityMutationType.CREATE }
|
||||||
);
|
);
|
||||||
await this.executeMutationTriggersOrValidatorsAsync(
|
await this.executeMutationTriggersAsync(
|
||||||
this.mutationTriggers.beforeAll,
|
this.mutationTriggers.beforeAll,
|
||||||
queryContext,
|
queryContext,
|
||||||
temporaryEntityForPrivacyCheck,
|
temporaryEntityForPrivacyCheck,
|
||||||
{ type: EntityMutationType.CREATE }
|
{ type: EntityMutationType.CREATE }
|
||||||
);
|
);
|
||||||
await this.executeMutationTriggersOrValidatorsAsync(
|
await this.executeMutationTriggersAsync(
|
||||||
this.mutationTriggers.beforeCreate,
|
this.mutationTriggers.beforeCreate,
|
||||||
queryContext,
|
queryContext,
|
||||||
temporaryEntityForPrivacyCheck,
|
temporaryEntityForPrivacyCheck,
|
||||||
@@ -249,7 +250,7 @@ export class CreateMutator<
|
|||||||
const insertResult = await this.databaseAdapter.insertAsync(queryContext, this.fieldsForEntity);
|
const insertResult = await this.databaseAdapter.insertAsync(queryContext, this.fieldsForEntity);
|
||||||
|
|
||||||
const entityLoader = this.entityLoaderFactory.forLoad(this.viewerContext, queryContext);
|
const entityLoader = this.entityLoaderFactory.forLoad(this.viewerContext, queryContext);
|
||||||
queryContext.appendPostCommitCallback(
|
queryContext.appendPostCommitInvalidationCallback(
|
||||||
entityLoader.invalidateFieldsAsync.bind(entityLoader, insertResult)
|
entityLoader.invalidateFieldsAsync.bind(entityLoader, insertResult)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -258,13 +259,13 @@ export class CreateMutator<
|
|||||||
.enforcing()
|
.enforcing()
|
||||||
.loadByIDAsync(unauthorizedEntityAfterInsert.getID());
|
.loadByIDAsync(unauthorizedEntityAfterInsert.getID());
|
||||||
|
|
||||||
await this.executeMutationTriggersOrValidatorsAsync(
|
await this.executeMutationTriggersAsync(
|
||||||
this.mutationTriggers.afterCreate,
|
this.mutationTriggers.afterCreate,
|
||||||
queryContext,
|
queryContext,
|
||||||
newEntity,
|
newEntity,
|
||||||
{ type: EntityMutationType.CREATE }
|
{ type: EntityMutationType.CREATE }
|
||||||
);
|
);
|
||||||
await this.executeMutationTriggersOrValidatorsAsync(
|
await this.executeMutationTriggersAsync(
|
||||||
this.mutationTriggers.afterAll,
|
this.mutationTriggers.afterAll,
|
||||||
queryContext,
|
queryContext,
|
||||||
newEntity,
|
newEntity,
|
||||||
@@ -272,7 +273,7 @@ export class CreateMutator<
|
|||||||
);
|
);
|
||||||
|
|
||||||
queryContext.appendPostCommitCallback(
|
queryContext.appendPostCommitCallback(
|
||||||
this.executeNonTransactionalMutationTriggersOrValidatorsAsync.bind(
|
this.executeNonTransactionalMutationTriggersAsync.bind(
|
||||||
this,
|
this,
|
||||||
this.mutationTriggers.afterCommit,
|
this.mutationTriggers.afterCommit,
|
||||||
newEntity,
|
newEntity,
|
||||||
@@ -408,26 +409,27 @@ export class UpdateMutator<
|
|||||||
this.privacyPolicy.authorizeUpdateAsync(
|
this.privacyPolicy.authorizeUpdateAsync(
|
||||||
this.viewerContext,
|
this.viewerContext,
|
||||||
queryContext,
|
queryContext,
|
||||||
entityAboutToBeUpdated
|
entityAboutToBeUpdated,
|
||||||
|
this.metricsAdapter
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
if (!authorizeUpdateResult.ok) {
|
if (!authorizeUpdateResult.ok) {
|
||||||
return authorizeUpdateResult;
|
return authorizeUpdateResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.executeMutationTriggersOrValidatorsAsync(
|
await this.executeMutationValidatorsAsync(
|
||||||
this.mutationValidators,
|
this.mutationValidators,
|
||||||
queryContext,
|
queryContext,
|
||||||
entityAboutToBeUpdated,
|
entityAboutToBeUpdated,
|
||||||
{ type: EntityMutationType.UPDATE, previousValue: this.originalEntity }
|
{ type: EntityMutationType.UPDATE, previousValue: this.originalEntity }
|
||||||
);
|
);
|
||||||
await this.executeMutationTriggersOrValidatorsAsync(
|
await this.executeMutationTriggersAsync(
|
||||||
this.mutationTriggers.beforeAll,
|
this.mutationTriggers.beforeAll,
|
||||||
queryContext,
|
queryContext,
|
||||||
entityAboutToBeUpdated,
|
entityAboutToBeUpdated,
|
||||||
{ type: EntityMutationType.UPDATE, previousValue: this.originalEntity }
|
{ type: EntityMutationType.UPDATE, previousValue: this.originalEntity }
|
||||||
);
|
);
|
||||||
await this.executeMutationTriggersOrValidatorsAsync(
|
await this.executeMutationTriggersAsync(
|
||||||
this.mutationTriggers.beforeUpdate,
|
this.mutationTriggers.beforeUpdate,
|
||||||
queryContext,
|
queryContext,
|
||||||
entityAboutToBeUpdated,
|
entityAboutToBeUpdated,
|
||||||
@@ -443,13 +445,13 @@ export class UpdateMutator<
|
|||||||
|
|
||||||
const entityLoader = this.entityLoaderFactory.forLoad(this.viewerContext, queryContext);
|
const entityLoader = this.entityLoaderFactory.forLoad(this.viewerContext, queryContext);
|
||||||
|
|
||||||
queryContext.appendPostCommitCallback(
|
queryContext.appendPostCommitInvalidationCallback(
|
||||||
entityLoader.invalidateFieldsAsync.bind(
|
entityLoader.invalidateFieldsAsync.bind(
|
||||||
entityLoader,
|
entityLoader,
|
||||||
this.originalEntity.getAllDatabaseFields()
|
this.originalEntity.getAllDatabaseFields()
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
queryContext.appendPostCommitCallback(
|
queryContext.appendPostCommitInvalidationCallback(
|
||||||
entityLoader.invalidateFieldsAsync.bind(entityLoader, this.fieldsForEntity)
|
entityLoader.invalidateFieldsAsync.bind(entityLoader, this.fieldsForEntity)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -458,13 +460,13 @@ export class UpdateMutator<
|
|||||||
.enforcing()
|
.enforcing()
|
||||||
.loadByIDAsync(unauthorizedEntityAfterUpdate.getID());
|
.loadByIDAsync(unauthorizedEntityAfterUpdate.getID());
|
||||||
|
|
||||||
await this.executeMutationTriggersOrValidatorsAsync(
|
await this.executeMutationTriggersAsync(
|
||||||
this.mutationTriggers.afterUpdate,
|
this.mutationTriggers.afterUpdate,
|
||||||
queryContext,
|
queryContext,
|
||||||
updatedEntity,
|
updatedEntity,
|
||||||
{ type: EntityMutationType.UPDATE, previousValue: this.originalEntity }
|
{ type: EntityMutationType.UPDATE, previousValue: this.originalEntity }
|
||||||
);
|
);
|
||||||
await this.executeMutationTriggersOrValidatorsAsync(
|
await this.executeMutationTriggersAsync(
|
||||||
this.mutationTriggers.afterAll,
|
this.mutationTriggers.afterAll,
|
||||||
queryContext,
|
queryContext,
|
||||||
updatedEntity,
|
updatedEntity,
|
||||||
@@ -472,7 +474,7 @@ export class UpdateMutator<
|
|||||||
);
|
);
|
||||||
|
|
||||||
queryContext.appendPostCommitCallback(
|
queryContext.appendPostCommitCallback(
|
||||||
this.executeNonTransactionalMutationTriggersOrValidatorsAsync.bind(
|
this.executeNonTransactionalMutationTriggersAsync.bind(
|
||||||
this,
|
this,
|
||||||
this.mutationTriggers.afterCommit,
|
this.mutationTriggers.afterCommit,
|
||||||
updatedEntity,
|
updatedEntity,
|
||||||
@@ -563,7 +565,7 @@ export class DeleteMutator<
|
|||||||
this.metricsAdapter,
|
this.metricsAdapter,
|
||||||
EntityMetricsMutationType.DELETE,
|
EntityMetricsMutationType.DELETE,
|
||||||
this.entityClass.name
|
this.entityClass.name
|
||||||
)(this.deleteInTransactionAsync());
|
)(this.deleteInTransactionAsync(new Set(), false, null));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -574,14 +576,16 @@ export class DeleteMutator<
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async deleteInTransactionAsync(
|
private async deleteInTransactionAsync(
|
||||||
processedEntityIdentifiersFromTransitiveDeletions: Set<string> = new Set(),
|
processedEntityIdentifiersFromTransitiveDeletions: Set<string>,
|
||||||
skipDatabaseDeletion: boolean = false
|
skipDatabaseDeletion: boolean,
|
||||||
|
cascadingDeleteCause: EntityMutationTriggerDeleteCascadeInfo | null
|
||||||
): Promise<Result<void>> {
|
): Promise<Result<void>> {
|
||||||
return await this.queryContext.runInTransactionIfNotInTransactionAsync((innerQueryContext) =>
|
return await this.queryContext.runInTransactionIfNotInTransactionAsync((innerQueryContext) =>
|
||||||
this.deleteInternalAsync(
|
this.deleteInternalAsync(
|
||||||
innerQueryContext,
|
innerQueryContext,
|
||||||
processedEntityIdentifiersFromTransitiveDeletions,
|
processedEntityIdentifiersFromTransitiveDeletions,
|
||||||
skipDatabaseDeletion
|
skipDatabaseDeletion,
|
||||||
|
cascadingDeleteCause
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -589,10 +593,16 @@ export class DeleteMutator<
|
|||||||
private async deleteInternalAsync(
|
private async deleteInternalAsync(
|
||||||
queryContext: EntityTransactionalQueryContext,
|
queryContext: EntityTransactionalQueryContext,
|
||||||
processedEntityIdentifiersFromTransitiveDeletions: Set<string>,
|
processedEntityIdentifiersFromTransitiveDeletions: Set<string>,
|
||||||
skipDatabaseDeletion: boolean
|
skipDatabaseDeletion: boolean,
|
||||||
|
cascadingDeleteCause: EntityMutationTriggerDeleteCascadeInfo | null
|
||||||
): Promise<Result<void>> {
|
): Promise<Result<void>> {
|
||||||
const authorizeDeleteResult = await asyncResult(
|
const authorizeDeleteResult = await asyncResult(
|
||||||
this.privacyPolicy.authorizeDeleteAsync(this.viewerContext, queryContext, this.entity)
|
this.privacyPolicy.authorizeDeleteAsync(
|
||||||
|
this.viewerContext,
|
||||||
|
queryContext,
|
||||||
|
this.entity,
|
||||||
|
this.metricsAdapter
|
||||||
|
)
|
||||||
);
|
);
|
||||||
if (!authorizeDeleteResult.ok) {
|
if (!authorizeDeleteResult.ok) {
|
||||||
return authorizeDeleteResult;
|
return authorizeDeleteResult;
|
||||||
@@ -601,20 +611,21 @@ export class DeleteMutator<
|
|||||||
await this.processEntityDeletionForInboundEdgesAsync(
|
await this.processEntityDeletionForInboundEdgesAsync(
|
||||||
this.entity,
|
this.entity,
|
||||||
queryContext,
|
queryContext,
|
||||||
processedEntityIdentifiersFromTransitiveDeletions
|
processedEntityIdentifiersFromTransitiveDeletions,
|
||||||
|
cascadingDeleteCause
|
||||||
);
|
);
|
||||||
|
|
||||||
await this.executeMutationTriggersOrValidatorsAsync(
|
await this.executeMutationTriggersAsync(
|
||||||
this.mutationTriggers.beforeAll,
|
this.mutationTriggers.beforeAll,
|
||||||
queryContext,
|
queryContext,
|
||||||
this.entity,
|
this.entity,
|
||||||
{ type: EntityMutationType.DELETE }
|
{ type: EntityMutationType.DELETE, cascadingDeleteCause }
|
||||||
);
|
);
|
||||||
await this.executeMutationTriggersOrValidatorsAsync(
|
await this.executeMutationTriggersAsync(
|
||||||
this.mutationTriggers.beforeDelete,
|
this.mutationTriggers.beforeDelete,
|
||||||
queryContext,
|
queryContext,
|
||||||
this.entity,
|
this.entity,
|
||||||
{ type: EntityMutationType.DELETE }
|
{ type: EntityMutationType.DELETE, cascadingDeleteCause }
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!skipDatabaseDeletion) {
|
if (!skipDatabaseDeletion) {
|
||||||
@@ -626,29 +637,29 @@ export class DeleteMutator<
|
|||||||
}
|
}
|
||||||
|
|
||||||
const entityLoader = this.entityLoaderFactory.forLoad(this.viewerContext, queryContext);
|
const entityLoader = this.entityLoaderFactory.forLoad(this.viewerContext, queryContext);
|
||||||
queryContext.appendPostCommitCallback(
|
queryContext.appendPostCommitInvalidationCallback(
|
||||||
entityLoader.invalidateFieldsAsync.bind(entityLoader, this.entity.getAllDatabaseFields())
|
entityLoader.invalidateFieldsAsync.bind(entityLoader, this.entity.getAllDatabaseFields())
|
||||||
);
|
);
|
||||||
|
|
||||||
await this.executeMutationTriggersOrValidatorsAsync(
|
await this.executeMutationTriggersAsync(
|
||||||
this.mutationTriggers.afterDelete,
|
this.mutationTriggers.afterDelete,
|
||||||
queryContext,
|
queryContext,
|
||||||
this.entity,
|
this.entity,
|
||||||
{ type: EntityMutationType.DELETE }
|
{ type: EntityMutationType.DELETE, cascadingDeleteCause }
|
||||||
);
|
);
|
||||||
await this.executeMutationTriggersOrValidatorsAsync(
|
await this.executeMutationTriggersAsync(
|
||||||
this.mutationTriggers.afterAll,
|
this.mutationTriggers.afterAll,
|
||||||
queryContext,
|
queryContext,
|
||||||
this.entity,
|
this.entity,
|
||||||
{ type: EntityMutationType.DELETE }
|
{ type: EntityMutationType.DELETE, cascadingDeleteCause }
|
||||||
);
|
);
|
||||||
|
|
||||||
queryContext.appendPostCommitCallback(
|
queryContext.appendPostCommitCallback(
|
||||||
this.executeNonTransactionalMutationTriggersOrValidatorsAsync.bind(
|
this.executeNonTransactionalMutationTriggersAsync.bind(
|
||||||
this,
|
this,
|
||||||
this.mutationTriggers.afterCommit,
|
this.mutationTriggers.afterCommit,
|
||||||
this.entity,
|
this.entity,
|
||||||
{ type: EntityMutationType.DELETE }
|
{ type: EntityMutationType.DELETE, cascadingDeleteCause }
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -672,7 +683,8 @@ export class DeleteMutator<
|
|||||||
private async processEntityDeletionForInboundEdgesAsync(
|
private async processEntityDeletionForInboundEdgesAsync(
|
||||||
entity: TEntity,
|
entity: TEntity,
|
||||||
queryContext: EntityTransactionalQueryContext,
|
queryContext: EntityTransactionalQueryContext,
|
||||||
processedEntityIdentifiers: Set<string>
|
processedEntityIdentifiers: Set<string>,
|
||||||
|
cascadingDeleteCause: EntityMutationTriggerDeleteCascadeInfo | null
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// prevent infinite reference cycles by keeping track of entities already processed
|
// prevent infinite reference cycles by keeping track of entities already processed
|
||||||
if (processedEntityIdentifiers.has(entity.getUniqueIdentifier())) {
|
if (processedEntityIdentifiers.has(entity.getUniqueIdentifier())) {
|
||||||
@@ -745,7 +757,11 @@ export class DeleteMutator<
|
|||||||
.forDelete(inboundReferenceEntity, queryContext)
|
.forDelete(inboundReferenceEntity, queryContext)
|
||||||
.deleteInTransactionAsync(
|
.deleteInTransactionAsync(
|
||||||
processedEntityIdentifiers,
|
processedEntityIdentifiers,
|
||||||
/* skipDatabaseDeletion */ true // deletion is handled by DB
|
/* skipDatabaseDeletion */ true, // deletion is handled by DB
|
||||||
|
{
|
||||||
|
entity,
|
||||||
|
cascadingDeleteCause,
|
||||||
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -769,7 +785,11 @@ export class DeleteMutator<
|
|||||||
.forDelete(inboundReferenceEntity, queryContext)
|
.forDelete(inboundReferenceEntity, queryContext)
|
||||||
.deleteInTransactionAsync(
|
.deleteInTransactionAsync(
|
||||||
processedEntityIdentifiers,
|
processedEntityIdentifiers,
|
||||||
/* skipDatabaseDeletion */ false
|
/* skipDatabaseDeletion */ false,
|
||||||
|
{
|
||||||
|
entity,
|
||||||
|
cascadingDeleteCause,
|
||||||
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import { EntityQueryContext } from './EntityQueryContext';
|
|||||||
import ReadonlyEntity from './ReadonlyEntity';
|
import ReadonlyEntity from './ReadonlyEntity';
|
||||||
import ViewerContext from './ViewerContext';
|
import ViewerContext from './ViewerContext';
|
||||||
import EntityNotAuthorizedError from './errors/EntityNotAuthorizedError';
|
import EntityNotAuthorizedError from './errors/EntityNotAuthorizedError';
|
||||||
|
import IEntityMetricsAdapter, {
|
||||||
|
EntityMetricsAuthorizationResult,
|
||||||
|
} from './metrics/IEntityMetricsAdapter';
|
||||||
import PrivacyPolicyRule, { RuleEvaluationResult } from './rules/PrivacyPolicyRule';
|
import PrivacyPolicyRule, { RuleEvaluationResult } from './rules/PrivacyPolicyRule';
|
||||||
|
|
||||||
export enum EntityPrivacyPolicyEvaluationMode {
|
export enum EntityPrivacyPolicyEvaluationMode {
|
||||||
@@ -124,14 +127,16 @@ export default abstract class EntityPrivacyPolicy<
|
|||||||
async authorizeCreateAsync(
|
async authorizeCreateAsync(
|
||||||
viewerContext: TViewerContext,
|
viewerContext: TViewerContext,
|
||||||
queryContext: EntityQueryContext,
|
queryContext: EntityQueryContext,
|
||||||
entity: TEntity
|
entity: TEntity,
|
||||||
|
metricsAdapter: IEntityMetricsAdapter
|
||||||
): Promise<TEntity> {
|
): Promise<TEntity> {
|
||||||
return await this.authorizeForRulesetAsync(
|
return await this.authorizeForRulesetAsync(
|
||||||
this.createRules,
|
this.createRules,
|
||||||
viewerContext,
|
viewerContext,
|
||||||
queryContext,
|
queryContext,
|
||||||
entity,
|
entity,
|
||||||
EntityAuthorizationAction.CREATE
|
EntityAuthorizationAction.CREATE,
|
||||||
|
metricsAdapter
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,14 +151,16 @@ export default abstract class EntityPrivacyPolicy<
|
|||||||
async authorizeReadAsync(
|
async authorizeReadAsync(
|
||||||
viewerContext: TViewerContext,
|
viewerContext: TViewerContext,
|
||||||
queryContext: EntityQueryContext,
|
queryContext: EntityQueryContext,
|
||||||
entity: TEntity
|
entity: TEntity,
|
||||||
|
metricsAdapter: IEntityMetricsAdapter
|
||||||
): Promise<TEntity> {
|
): Promise<TEntity> {
|
||||||
return await this.authorizeForRulesetAsync(
|
return await this.authorizeForRulesetAsync(
|
||||||
this.readRules,
|
this.readRules,
|
||||||
viewerContext,
|
viewerContext,
|
||||||
queryContext,
|
queryContext,
|
||||||
entity,
|
entity,
|
||||||
EntityAuthorizationAction.READ
|
EntityAuthorizationAction.READ,
|
||||||
|
metricsAdapter
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,14 +175,16 @@ export default abstract class EntityPrivacyPolicy<
|
|||||||
async authorizeUpdateAsync(
|
async authorizeUpdateAsync(
|
||||||
viewerContext: TViewerContext,
|
viewerContext: TViewerContext,
|
||||||
queryContext: EntityQueryContext,
|
queryContext: EntityQueryContext,
|
||||||
entity: TEntity
|
entity: TEntity,
|
||||||
|
metricsAdapter: IEntityMetricsAdapter
|
||||||
): Promise<TEntity> {
|
): Promise<TEntity> {
|
||||||
return await this.authorizeForRulesetAsync(
|
return await this.authorizeForRulesetAsync(
|
||||||
this.updateRules,
|
this.updateRules,
|
||||||
viewerContext,
|
viewerContext,
|
||||||
queryContext,
|
queryContext,
|
||||||
entity,
|
entity,
|
||||||
EntityAuthorizationAction.UPDATE
|
EntityAuthorizationAction.UPDATE,
|
||||||
|
metricsAdapter
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,14 +199,16 @@ export default abstract class EntityPrivacyPolicy<
|
|||||||
async authorizeDeleteAsync(
|
async authorizeDeleteAsync(
|
||||||
viewerContext: TViewerContext,
|
viewerContext: TViewerContext,
|
||||||
queryContext: EntityQueryContext,
|
queryContext: EntityQueryContext,
|
||||||
entity: TEntity
|
entity: TEntity,
|
||||||
|
metricsAdapter: IEntityMetricsAdapter
|
||||||
): Promise<TEntity> {
|
): Promise<TEntity> {
|
||||||
return await this.authorizeForRulesetAsync(
|
return await this.authorizeForRulesetAsync(
|
||||||
this.deleteRules,
|
this.deleteRules,
|
||||||
viewerContext,
|
viewerContext,
|
||||||
queryContext,
|
queryContext,
|
||||||
entity,
|
entity,
|
||||||
EntityAuthorizationAction.DELETE
|
EntityAuthorizationAction.DELETE,
|
||||||
|
metricsAdapter
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,48 +217,95 @@ export default abstract class EntityPrivacyPolicy<
|
|||||||
viewerContext: TViewerContext,
|
viewerContext: TViewerContext,
|
||||||
queryContext: EntityQueryContext,
|
queryContext: EntityQueryContext,
|
||||||
entity: TEntity,
|
entity: TEntity,
|
||||||
action: EntityAuthorizationAction
|
action: EntityAuthorizationAction,
|
||||||
|
metricsAdapter: IEntityMetricsAdapter
|
||||||
): Promise<TEntity> {
|
): Promise<TEntity> {
|
||||||
const privacyPolicyEvaluator = this.getPrivacyPolicyEvaluator(viewerContext);
|
const privacyPolicyEvaluator = this.getPrivacyPolicyEvaluator(viewerContext);
|
||||||
switch (privacyPolicyEvaluator.mode) {
|
switch (privacyPolicyEvaluator.mode) {
|
||||||
case EntityPrivacyPolicyEvaluationMode.ENFORCE:
|
case EntityPrivacyPolicyEvaluationMode.ENFORCE:
|
||||||
return await this.authorizeForRulesetInnerAsync(
|
|
||||||
ruleset,
|
|
||||||
viewerContext,
|
|
||||||
queryContext,
|
|
||||||
entity,
|
|
||||||
action
|
|
||||||
);
|
|
||||||
case EntityPrivacyPolicyEvaluationMode.ENFORCE_AND_LOG:
|
|
||||||
try {
|
try {
|
||||||
return await this.authorizeForRulesetInnerAsync(
|
const result = await this.authorizeForRulesetInnerAsync(
|
||||||
ruleset,
|
ruleset,
|
||||||
viewerContext,
|
viewerContext,
|
||||||
queryContext,
|
queryContext,
|
||||||
entity,
|
entity,
|
||||||
action
|
action
|
||||||
);
|
);
|
||||||
|
metricsAdapter.logAuthorizationEvent({
|
||||||
|
entityClassName: entity.constructor.name,
|
||||||
|
action,
|
||||||
|
evaluationResult: EntityMetricsAuthorizationResult.ALLOW,
|
||||||
|
privacyPolicyEvaluationMode: privacyPolicyEvaluator.mode,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
if (!(e instanceof EntityNotAuthorizedError)) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
metricsAdapter.logAuthorizationEvent({
|
||||||
|
entityClassName: entity.constructor.name,
|
||||||
|
action,
|
||||||
|
evaluationResult: EntityMetricsAuthorizationResult.DENY,
|
||||||
|
privacyPolicyEvaluationMode: privacyPolicyEvaluator.mode,
|
||||||
|
});
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
case EntityPrivacyPolicyEvaluationMode.ENFORCE_AND_LOG:
|
||||||
|
try {
|
||||||
|
const result = await this.authorizeForRulesetInnerAsync(
|
||||||
|
ruleset,
|
||||||
|
viewerContext,
|
||||||
|
queryContext,
|
||||||
|
entity,
|
||||||
|
action
|
||||||
|
);
|
||||||
|
metricsAdapter.logAuthorizationEvent({
|
||||||
|
entityClassName: entity.constructor.name,
|
||||||
|
action,
|
||||||
|
evaluationResult: EntityMetricsAuthorizationResult.ALLOW,
|
||||||
|
privacyPolicyEvaluationMode: privacyPolicyEvaluator.mode,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!(e instanceof EntityNotAuthorizedError)) {
|
if (!(e instanceof EntityNotAuthorizedError)) {
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
privacyPolicyEvaluator.denyHandler(e);
|
privacyPolicyEvaluator.denyHandler(e);
|
||||||
|
metricsAdapter.logAuthorizationEvent({
|
||||||
|
entityClassName: entity.constructor.name,
|
||||||
|
action,
|
||||||
|
evaluationResult: EntityMetricsAuthorizationResult.DENY,
|
||||||
|
privacyPolicyEvaluationMode: privacyPolicyEvaluator.mode,
|
||||||
|
});
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
case EntityPrivacyPolicyEvaluationMode.DRY_RUN:
|
case EntityPrivacyPolicyEvaluationMode.DRY_RUN:
|
||||||
try {
|
try {
|
||||||
return await this.authorizeForRulesetInnerAsync(
|
const result = await this.authorizeForRulesetInnerAsync(
|
||||||
ruleset,
|
ruleset,
|
||||||
viewerContext,
|
viewerContext,
|
||||||
queryContext,
|
queryContext,
|
||||||
entity,
|
entity,
|
||||||
action
|
action
|
||||||
);
|
);
|
||||||
|
metricsAdapter.logAuthorizationEvent({
|
||||||
|
entityClassName: entity.constructor.name,
|
||||||
|
action,
|
||||||
|
evaluationResult: EntityMetricsAuthorizationResult.ALLOW,
|
||||||
|
privacyPolicyEvaluationMode: privacyPolicyEvaluator.mode,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!(e instanceof EntityNotAuthorizedError)) {
|
if (!(e instanceof EntityNotAuthorizedError)) {
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
privacyPolicyEvaluator.denyHandler(e);
|
privacyPolicyEvaluator.denyHandler(e);
|
||||||
|
metricsAdapter.logAuthorizationEvent({
|
||||||
|
entityClassName: entity.constructor.name,
|
||||||
|
action,
|
||||||
|
evaluationResult: EntityMetricsAuthorizationResult.DENY,
|
||||||
|
privacyPolicyEvaluationMode: privacyPolicyEvaluator.mode,
|
||||||
|
});
|
||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
|
import assert from 'assert';
|
||||||
|
|
||||||
import EntityQueryContextProvider from './EntityQueryContextProvider';
|
import EntityQueryContextProvider from './EntityQueryContextProvider';
|
||||||
|
|
||||||
export type PostCommitCallback = (...args: any) => Promise<any>;
|
export type PostCommitCallback = (...args: any) => Promise<any>;
|
||||||
|
export type PreCommitCallback = (
|
||||||
|
queryContext: EntityTransactionalQueryContext,
|
||||||
|
...args: any
|
||||||
|
) => Promise<any>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Entity framework representation of transactional and non-transactional database
|
* Entity framework representation of transactional and non-transactional database
|
||||||
@@ -43,13 +49,61 @@ export class EntityNonTransactionalQueryContext extends EntityQueryContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class EntityTransactionalQueryContext extends EntityQueryContext {
|
export class EntityTransactionalQueryContext extends EntityQueryContext {
|
||||||
|
private readonly postCommitInvalidationCallbacks: PostCommitCallback[] = [];
|
||||||
private readonly postCommitCallbacks: PostCommitCallback[] = [];
|
private readonly postCommitCallbacks: PostCommitCallback[] = [];
|
||||||
|
|
||||||
|
private readonly preCommitCallbacks: { callback: PreCommitCallback; order: number }[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule a pre-commit callback. These will be run within the transaction right before it is
|
||||||
|
* committed, and will be run in the order specified. Ordering of callbacks scheduled with the
|
||||||
|
* same value for the order parameter is undefined within that ordering group.
|
||||||
|
* @param callback - callback to schedule
|
||||||
|
* @param order - order in which this should be run relative to other scheduled pre-commit callbacks,
|
||||||
|
* with higher numbers running later than lower numbers.
|
||||||
|
*/
|
||||||
|
public appendPreCommitCallback(callback: PreCommitCallback, order: number): void {
|
||||||
|
assert(
|
||||||
|
order >= Number.MIN_SAFE_INTEGER && order <= Number.MAX_SAFE_INTEGER,
|
||||||
|
`Invalid order specified: ${order}`
|
||||||
|
);
|
||||||
|
this.preCommitCallbacks.push({ callback, order });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule a post-commit cache invalidation callback. These are run before normal
|
||||||
|
* post-commit callbacks in order to have cache consistency in normal post-commit callbacks.
|
||||||
|
* @param callback - callback to schedule
|
||||||
|
*/
|
||||||
|
public appendPostCommitInvalidationCallback(callback: PostCommitCallback): void {
|
||||||
|
this.postCommitInvalidationCallbacks.push(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule a post-commit callback. These will be run after the transaction has
|
||||||
|
* been committed.
|
||||||
|
* @param callback - callback to schedule
|
||||||
|
*/
|
||||||
public appendPostCommitCallback(callback: PostCommitCallback): void {
|
public appendPostCommitCallback(callback: PostCommitCallback): void {
|
||||||
this.postCommitCallbacks.push(callback);
|
this.postCommitCallbacks.push(callback);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async runPreCommitCallbacksAsync(): Promise<void> {
|
||||||
|
const callbacks = [...this.preCommitCallbacks]
|
||||||
|
.sort((a, b) => a.order - b.order)
|
||||||
|
.map((c) => c.callback);
|
||||||
|
this.preCommitCallbacks.length = 0;
|
||||||
|
|
||||||
|
for (const callback of callbacks) {
|
||||||
|
await callback(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async runPostCommitCallbacksAsync(): Promise<void> {
|
public async runPostCommitCallbacksAsync(): Promise<void> {
|
||||||
|
const invalidationCallbacks = [...this.postCommitInvalidationCallbacks];
|
||||||
|
this.postCommitInvalidationCallbacks.length = 0;
|
||||||
|
await Promise.all(invalidationCallbacks.map((callback) => callback()));
|
||||||
|
|
||||||
const callbacks = [...this.postCommitCallbacks];
|
const callbacks = [...this.postCommitCallbacks];
|
||||||
this.postCommitCallbacks.length = 0;
|
this.postCommitCallbacks.length = 0;
|
||||||
await Promise.all(callbacks.map((callback) => callback()));
|
await Promise.all(callbacks.map((callback) => callback()));
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export default abstract class EntityQueryContextProvider {
|
|||||||
>()(async (queryInterface) => {
|
>()(async (queryInterface) => {
|
||||||
const queryContext = new EntityTransactionalQueryContext(queryInterface);
|
const queryContext = new EntityTransactionalQueryContext(queryInterface);
|
||||||
const result = await transactionScope(queryContext);
|
const result = await transactionScope(queryContext);
|
||||||
|
await queryContext.runPreCommitCallbacksAsync();
|
||||||
return [result, queryContext];
|
return [result, queryContext];
|
||||||
});
|
});
|
||||||
await queryContext.runPostCommitCallbacksAsync();
|
await queryContext.runPostCommitCallbacksAsync();
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import invariant from 'invariant';
|
||||||
|
|
||||||
|
import { ISecondaryEntityCache } from './EntitySecondaryCacheLoader';
|
||||||
|
import IEntityGenericCacher from './IEntityGenericCacher';
|
||||||
|
import { CacheStatus } from './internal/ReadThroughEntityCache';
|
||||||
|
import { filterMap, zipToMap } from './utils/collections/maps';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A custom secondary read-through entity cache is a way to add a custom second layer of caching for a particular
|
||||||
|
* single entity load. One common way this may be used is to add a second layer of caching in a hot path that makes
|
||||||
|
* a call to {@link EntityLoader.loadManyByFieldEqualityConjunctionAsync} is guaranteed to return at most one entity.
|
||||||
|
*/
|
||||||
|
export default abstract class GenericSecondaryEntityCache<TFields, TLoadParams>
|
||||||
|
implements ISecondaryEntityCache<TFields, TLoadParams>
|
||||||
|
{
|
||||||
|
constructor(
|
||||||
|
protected readonly cacher: IEntityGenericCacher<TFields>,
|
||||||
|
protected readonly constructCacheKey: (params: Readonly<TLoadParams>) => string
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async loadManyThroughAsync(
|
||||||
|
loadParamsArray: readonly Readonly<TLoadParams>[],
|
||||||
|
fetcher: (
|
||||||
|
fetcherLoadParamsArray: readonly Readonly<TLoadParams>[]
|
||||||
|
) => Promise<ReadonlyMap<Readonly<TLoadParams>, Readonly<TFields> | null>>
|
||||||
|
): Promise<ReadonlyMap<Readonly<TLoadParams>, Readonly<TFields> | null>> {
|
||||||
|
const cacheKeys = loadParamsArray.map(this.constructCacheKey);
|
||||||
|
const cacheKeyToLoadParamsMap = zipToMap(cacheKeys, loadParamsArray);
|
||||||
|
|
||||||
|
const cacheLoadResults = await this.cacher.loadManyAsync(cacheKeys);
|
||||||
|
|
||||||
|
invariant(
|
||||||
|
cacheLoadResults.size === loadParamsArray.length,
|
||||||
|
`${this.constructor.name} loadMany should return a result for each key`
|
||||||
|
);
|
||||||
|
|
||||||
|
const cacheKeysToFetch = Array.from(
|
||||||
|
filterMap(
|
||||||
|
cacheLoadResults,
|
||||||
|
(cacheLoadResult) => cacheLoadResult.status === CacheStatus.MISS
|
||||||
|
).keys()
|
||||||
|
);
|
||||||
|
|
||||||
|
// put cache hits in result map
|
||||||
|
const results: Map<Readonly<TLoadParams>, Readonly<TFields> | null> = new Map();
|
||||||
|
cacheLoadResults.forEach((cacheLoadResult, cacheKey) => {
|
||||||
|
if (cacheLoadResult.status === CacheStatus.HIT) {
|
||||||
|
const loadParams = cacheKeyToLoadParamsMap.get(cacheKey);
|
||||||
|
invariant(loadParams !== undefined, 'load params should be in cache key map');
|
||||||
|
results.set(loadParams, cacheLoadResult.item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// fetch any misses from DB, add DB objects to results, cache DB results, inform cache of any missing DB results
|
||||||
|
if (cacheKeysToFetch.length > 0) {
|
||||||
|
const loadParamsToFetch = cacheKeysToFetch.map((cacheKey) => {
|
||||||
|
const loadParams = cacheKeyToLoadParamsMap.get(cacheKey);
|
||||||
|
invariant(loadParams !== undefined, 'load params should be in cache key map');
|
||||||
|
return loadParams;
|
||||||
|
});
|
||||||
|
const fetchResults = await fetcher(loadParamsToFetch);
|
||||||
|
|
||||||
|
const fetchMisses = loadParamsToFetch.filter((loadParams) => {
|
||||||
|
// all values of fetchResults should be field objects or undefined
|
||||||
|
return !fetchResults.get(loadParams);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const fetchMiss of fetchMisses) {
|
||||||
|
results.set(fetchMiss, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const objectsToCache: Map<string, Readonly<TFields>> = new Map();
|
||||||
|
for (const [loadParams, object] of fetchResults.entries()) {
|
||||||
|
if (object) {
|
||||||
|
objectsToCache.set(this.constructCacheKey(loadParams), object);
|
||||||
|
results.set(loadParams, object);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
this.cacher.cacheManyAsync(objectsToCache),
|
||||||
|
this.cacher.cacheDBMissesAsync(fetchMisses.map(this.constructCacheKey)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidate the cache for objects cached by constructCacheKey(loadParams).
|
||||||
|
*
|
||||||
|
* @param loadParamsArray - load params to invalidate
|
||||||
|
*/
|
||||||
|
public invalidateManyAsync(loadParamsArray: readonly Readonly<TLoadParams>[]): Promise<void> {
|
||||||
|
const cacheKeys = loadParamsArray.map(this.constructCacheKey);
|
||||||
|
return this.cacher.invalidateManyAsync(cacheKeys);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { CacheLoadResult } from './internal/ReadThroughEntityCache';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A cacher stores and loads key-value pairs. It also supports negative caching - it stores the absence
|
||||||
|
* of keys that don't exist in the backing datastore.
|
||||||
|
*/
|
||||||
|
export default interface IEntityGenericCacher<TFields> {
|
||||||
|
loadManyAsync(keys: readonly string[]): Promise<ReadonlyMap<string, CacheLoadResult<TFields>>>;
|
||||||
|
|
||||||
|
cacheManyAsync(objectMap: ReadonlyMap<string, Readonly<TFields>>): Promise<void>;
|
||||||
|
|
||||||
|
cacheDBMissesAsync(keys: readonly string[]): Promise<void>;
|
||||||
|
|
||||||
|
invalidateManyAsync(keys: readonly string[]): Promise<void>;
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import ReadonlyEntity from './ReadonlyEntity';
|
|||||||
import ViewerContext from './ViewerContext';
|
import ViewerContext from './ViewerContext';
|
||||||
import ViewerScopedEntityLoaderFactory from './ViewerScopedEntityLoaderFactory';
|
import ViewerScopedEntityLoaderFactory from './ViewerScopedEntityLoaderFactory';
|
||||||
import ViewerScopedEntityMutatorFactory from './ViewerScopedEntityMutatorFactory';
|
import ViewerScopedEntityMutatorFactory from './ViewerScopedEntityMutatorFactory';
|
||||||
|
import IEntityMetricsAdapter from './metrics/IEntityMetricsAdapter';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provides a simpler API for loading and mutating entities by injecting the {@link ViewerContext}
|
* Provides a simpler API for loading and mutating entities by injecting the {@link ViewerContext}
|
||||||
@@ -76,4 +77,11 @@ export default class ViewerScopedEntityCompanion<
|
|||||||
getQueryContextProvider(): EntityQueryContextProvider {
|
getQueryContextProvider(): EntityQueryContextProvider {
|
||||||
return this.entityCompanion.getQueryContextProvider();
|
return this.entityCompanion.getQueryContextProvider();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the {@link IEntityMetricsAdapter} for this companion.
|
||||||
|
*/
|
||||||
|
getMetricsAdapter(): IEntityMetricsAdapter {
|
||||||
|
return this.entityCompanion.getMetricsAdapter();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,10 @@ import { EntityCompanionDefinition } from '../EntityCompanionProvider';
|
|||||||
import EntityConfiguration from '../EntityConfiguration';
|
import EntityConfiguration from '../EntityConfiguration';
|
||||||
import { EntityEdgeDeletionBehavior } from '../EntityFieldDefinition';
|
import { EntityEdgeDeletionBehavior } from '../EntityFieldDefinition';
|
||||||
import { UUIDField } from '../EntityFields';
|
import { UUIDField } from '../EntityFields';
|
||||||
|
import { EntityTriggerMutationInfo, EntityMutationType } from '../EntityMutationInfo';
|
||||||
|
import { EntityMutationTrigger } from '../EntityMutationTriggerConfiguration';
|
||||||
import EntityPrivacyPolicy from '../EntityPrivacyPolicy';
|
import EntityPrivacyPolicy from '../EntityPrivacyPolicy';
|
||||||
|
import { EntityTransactionalQueryContext } from '../EntityQueryContext';
|
||||||
import { CacheStatus } from '../internal/ReadThroughEntityCache';
|
import { CacheStatus } from '../internal/ReadThroughEntityCache';
|
||||||
import AlwaysAllowPrivacyPolicyRule from '../rules/AlwaysAllowPrivacyPolicyRule';
|
import AlwaysAllowPrivacyPolicyRule from '../rules/AlwaysAllowPrivacyPolicyRule';
|
||||||
import TestViewerContext from '../testfixtures/TestViewerContext';
|
import TestViewerContext from '../testfixtures/TestViewerContext';
|
||||||
@@ -47,6 +50,125 @@ class TestEntityPrivacyPolicy extends EntityPrivacyPolicy<
|
|||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
|
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
|
||||||
const makeEntityClasses = (edgeDeletionBehavior: EntityEdgeDeletionBehavior) => {
|
const makeEntityClasses = (edgeDeletionBehavior: EntityEdgeDeletionBehavior) => {
|
||||||
|
const triggerExecutionCounts = {
|
||||||
|
parent: 0,
|
||||||
|
child: 0,
|
||||||
|
grandchild: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
class ParentCheckInfoTrigger extends EntityMutationTrigger<
|
||||||
|
ParentFields,
|
||||||
|
string,
|
||||||
|
TestViewerContext,
|
||||||
|
ParentEntity
|
||||||
|
> {
|
||||||
|
async executeAsync(
|
||||||
|
_viewerContext: TestViewerContext,
|
||||||
|
_queryContext: EntityTransactionalQueryContext,
|
||||||
|
_entity: ParentEntity,
|
||||||
|
mutationInfo: EntityTriggerMutationInfo<ParentFields, string, TestViewerContext, ParentEntity>
|
||||||
|
): Promise<void> {
|
||||||
|
if (mutationInfo.type !== EntityMutationType.DELETE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mutationInfo.cascadingDeleteCause !== null) {
|
||||||
|
throw new Error('Parent entity should not have casade delete cause');
|
||||||
|
}
|
||||||
|
|
||||||
|
triggerExecutionCounts.parent++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ChildCheckInfoTrigger extends EntityMutationTrigger<
|
||||||
|
ChildFields,
|
||||||
|
string,
|
||||||
|
TestViewerContext,
|
||||||
|
ChildEntity
|
||||||
|
> {
|
||||||
|
async executeAsync(
|
||||||
|
_viewerContext: TestViewerContext,
|
||||||
|
_queryContext: EntityTransactionalQueryContext,
|
||||||
|
_entity: ChildEntity,
|
||||||
|
mutationInfo: EntityTriggerMutationInfo<ChildFields, string, TestViewerContext, ChildEntity>
|
||||||
|
): Promise<void> {
|
||||||
|
if (mutationInfo.type !== EntityMutationType.DELETE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mutationInfo.cascadingDeleteCause === null) {
|
||||||
|
throw new Error('Child entity should have casade delete cause');
|
||||||
|
}
|
||||||
|
|
||||||
|
const cascadingDeleteCauseEntity = mutationInfo.cascadingDeleteCause.entity;
|
||||||
|
if (!(cascadingDeleteCauseEntity instanceof ParentEntity)) {
|
||||||
|
throw new Error('Child entity should have casade delete cause entity of type ParentEntity');
|
||||||
|
}
|
||||||
|
|
||||||
|
const secondLevelCascadingDeleteCause =
|
||||||
|
mutationInfo.cascadingDeleteCause.cascadingDeleteCause;
|
||||||
|
if (secondLevelCascadingDeleteCause) {
|
||||||
|
throw new Error('Child entity should not have two-level casade delete cause');
|
||||||
|
}
|
||||||
|
|
||||||
|
triggerExecutionCounts.child++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class GrandChildCheckInfoTrigger extends EntityMutationTrigger<
|
||||||
|
GrandChildFields,
|
||||||
|
string,
|
||||||
|
TestViewerContext,
|
||||||
|
GrandChildEntity
|
||||||
|
> {
|
||||||
|
async executeAsync(
|
||||||
|
_viewerContext: TestViewerContext,
|
||||||
|
_queryContext: EntityTransactionalQueryContext,
|
||||||
|
_entity: GrandChildEntity,
|
||||||
|
mutationInfo: EntityTriggerMutationInfo<
|
||||||
|
GrandChildFields,
|
||||||
|
string,
|
||||||
|
TestViewerContext,
|
||||||
|
GrandChildEntity
|
||||||
|
>
|
||||||
|
): Promise<void> {
|
||||||
|
if (mutationInfo.type !== EntityMutationType.DELETE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mutationInfo.cascadingDeleteCause === null) {
|
||||||
|
throw new Error('GrandChild entity should have cascade delete cause');
|
||||||
|
}
|
||||||
|
|
||||||
|
const cascadingDeleteCauseEntity = mutationInfo.cascadingDeleteCause.entity;
|
||||||
|
if (!(cascadingDeleteCauseEntity instanceof ChildEntity)) {
|
||||||
|
throw new Error(
|
||||||
|
'GrandChild entity should have cascade delete cause entity of type ChildEntity'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const secondLevelCascadingDeleteCause =
|
||||||
|
mutationInfo.cascadingDeleteCause.cascadingDeleteCause;
|
||||||
|
if (!secondLevelCascadingDeleteCause) {
|
||||||
|
throw new Error('GrandChild entity should have two-level casade delete cause');
|
||||||
|
}
|
||||||
|
|
||||||
|
const secondLevelCascadingDeleteCauseEntity = secondLevelCascadingDeleteCause.entity;
|
||||||
|
if (!(secondLevelCascadingDeleteCauseEntity instanceof ParentEntity)) {
|
||||||
|
throw new Error(
|
||||||
|
'GrandChild entity should have second level casade delete cause entity of type ParentEntity'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const thirdLevelCascadingDeleteCause = secondLevelCascadingDeleteCause.cascadingDeleteCause;
|
||||||
|
if (thirdLevelCascadingDeleteCause) {
|
||||||
|
throw new Error('GrandChild entity should not have three-level casade delete cause');
|
||||||
|
}
|
||||||
|
|
||||||
|
triggerExecutionCounts.grandchild++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class ParentEntity extends Entity<ParentFields, string, TestViewerContext> {
|
class ParentEntity extends Entity<ParentFields, string, TestViewerContext> {
|
||||||
static getCompanionDefinition(): EntityCompanionDefinition<
|
static getCompanionDefinition(): EntityCompanionDefinition<
|
||||||
ParentFields,
|
ParentFields,
|
||||||
@@ -144,33 +266,45 @@ const makeEntityClasses = (edgeDeletionBehavior: EntityEdgeDeletionBehavior) =>
|
|||||||
entityClass: ParentEntity,
|
entityClass: ParentEntity,
|
||||||
entityConfiguration: parentEntityConfiguration,
|
entityConfiguration: parentEntityConfiguration,
|
||||||
privacyPolicyClass: TestEntityPrivacyPolicy,
|
privacyPolicyClass: TestEntityPrivacyPolicy,
|
||||||
|
mutationTriggers: () => ({
|
||||||
|
beforeDelete: [new ParentCheckInfoTrigger()],
|
||||||
|
afterDelete: [new ParentCheckInfoTrigger()],
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const childEntityCompanion = new EntityCompanionDefinition({
|
const childEntityCompanion = new EntityCompanionDefinition({
|
||||||
entityClass: ChildEntity,
|
entityClass: ChildEntity,
|
||||||
entityConfiguration: childEntityConfiguration,
|
entityConfiguration: childEntityConfiguration,
|
||||||
privacyPolicyClass: TestEntityPrivacyPolicy,
|
privacyPolicyClass: TestEntityPrivacyPolicy,
|
||||||
|
mutationTriggers: () => ({
|
||||||
|
beforeDelete: [new ChildCheckInfoTrigger()],
|
||||||
|
afterDelete: [new ChildCheckInfoTrigger()],
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const grandChildEntityCompanion = new EntityCompanionDefinition({
|
const grandChildEntityCompanion = new EntityCompanionDefinition({
|
||||||
entityClass: GrandChildEntity,
|
entityClass: GrandChildEntity,
|
||||||
entityConfiguration: grandChildEntityConfiguration,
|
entityConfiguration: grandChildEntityConfiguration,
|
||||||
privacyPolicyClass: TestEntityPrivacyPolicy,
|
privacyPolicyClass: TestEntityPrivacyPolicy,
|
||||||
|
mutationTriggers: () => ({
|
||||||
|
beforeDelete: [new GrandChildCheckInfoTrigger()],
|
||||||
|
afterDelete: [new GrandChildCheckInfoTrigger()],
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ParentEntity,
|
ParentEntity,
|
||||||
ChildEntity,
|
ChildEntity,
|
||||||
GrandChildEntity,
|
GrandChildEntity,
|
||||||
|
triggerExecutionCounts,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
|
describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
|
||||||
describe('EntityEdgeDeletionBehavior.CASCADE_DELETE', () => {
|
describe('EntityEdgeDeletionBehavior.CASCADE_DELETE', () => {
|
||||||
it('deletes', async () => {
|
it('deletes', async () => {
|
||||||
const { ParentEntity, ChildEntity, GrandChildEntity } = makeEntityClasses(
|
const { ParentEntity, ChildEntity, GrandChildEntity, triggerExecutionCounts } =
|
||||||
EntityEdgeDeletionBehavior.CASCADE_DELETE
|
makeEntityClasses(EntityEdgeDeletionBehavior.CASCADE_DELETE);
|
||||||
);
|
|
||||||
const companionProvider = createUnitTestEntityCompanionProvider();
|
const companionProvider = createUnitTestEntityCompanionProvider();
|
||||||
const viewerContext = new TestViewerContext(companionProvider);
|
const viewerContext = new TestViewerContext(companionProvider);
|
||||||
|
|
||||||
@@ -203,14 +337,20 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
|
|||||||
await expect(
|
await expect(
|
||||||
GrandChildEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(grandchild.getID())
|
GrandChildEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(grandchild.getID())
|
||||||
).resolves.toBeNull();
|
).resolves.toBeNull();
|
||||||
|
|
||||||
|
// two calls for each trigger, one beforeDelete, one afterDelete
|
||||||
|
expect(triggerExecutionCounts).toMatchObject({
|
||||||
|
parent: 2,
|
||||||
|
child: 2,
|
||||||
|
grandchild: 2,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('EntityEdgeDeletionBehavior.SET_NULL', () => {
|
describe('EntityEdgeDeletionBehavior.SET_NULL', () => {
|
||||||
it('sets null', async () => {
|
it('sets null', async () => {
|
||||||
const { ParentEntity, ChildEntity, GrandChildEntity } = makeEntityClasses(
|
const { ParentEntity, ChildEntity, GrandChildEntity, triggerExecutionCounts } =
|
||||||
EntityEdgeDeletionBehavior.SET_NULL
|
makeEntityClasses(EntityEdgeDeletionBehavior.SET_NULL);
|
||||||
);
|
|
||||||
|
|
||||||
const companionProvider = createUnitTestEntityCompanionProvider();
|
const companionProvider = createUnitTestEntityCompanionProvider();
|
||||||
const viewerContext = new TestViewerContext(companionProvider);
|
const viewerContext = new TestViewerContext(companionProvider);
|
||||||
@@ -248,14 +388,21 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
|
|||||||
.enforcing()
|
.enforcing()
|
||||||
.loadByIDAsync(grandchild.getID());
|
.loadByIDAsync(grandchild.getID());
|
||||||
expect(loadedGrandchild.getField('parent_id')).toEqual(loadedChild.getID());
|
expect(loadedGrandchild.getField('parent_id')).toEqual(loadedChild.getID());
|
||||||
|
|
||||||
|
// two calls for only parent trigger, one beforeDelete, one afterDelete
|
||||||
|
// when using set null the descendants aren't deleted
|
||||||
|
expect(triggerExecutionCounts).toMatchObject({
|
||||||
|
parent: 2,
|
||||||
|
child: 0,
|
||||||
|
grandchild: 0,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE', () => {
|
describe('EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE', () => {
|
||||||
it('invalidates the cache', async () => {
|
it('invalidates the cache', async () => {
|
||||||
const { ParentEntity, ChildEntity, GrandChildEntity } = makeEntityClasses(
|
const { ParentEntity, ChildEntity, GrandChildEntity, triggerExecutionCounts } =
|
||||||
EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE
|
makeEntityClasses(EntityEdgeDeletionBehavior.CASCADE_DELETE_INVALIDATE_CACHE);
|
||||||
);
|
|
||||||
|
|
||||||
const companionProvider = createUnitTestEntityCompanionProvider();
|
const companionProvider = createUnitTestEntityCompanionProvider();
|
||||||
const viewerContext = new TestViewerContext(companionProvider);
|
const viewerContext = new TestViewerContext(companionProvider);
|
||||||
@@ -319,6 +466,13 @@ describe('EntityMutator.processEntityDeletionForInboundEdgesAsync', () => {
|
|||||||
await expect(
|
await expect(
|
||||||
GrandChildEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(grandchild.getID())
|
GrandChildEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(grandchild.getID())
|
||||||
).resolves.not.toBeNull();
|
).resolves.not.toBeNull();
|
||||||
|
|
||||||
|
// two calls for each trigger, one beforeDelete, one afterDelete
|
||||||
|
expect(triggerExecutionCounts).toMatchObject({
|
||||||
|
parent: 2,
|
||||||
|
child: 2,
|
||||||
|
grandchild: 2,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -118,6 +118,7 @@ export const testEntityCompanion = new EntityCompanionDefinition({
|
|||||||
describe(EntityLoader, () => {
|
describe(EntityLoader, () => {
|
||||||
it('handles thrown errors and literals from constructor', async () => {
|
it('handles thrown errors and literals from constructor', async () => {
|
||||||
const viewerContext = instance(mock(ViewerContext));
|
const viewerContext = instance(mock(ViewerContext));
|
||||||
|
const metricsAdapter = instance(mock<IEntityMetricsAdapter>());
|
||||||
const queryContext = StubQueryContextProvider.getQueryContext();
|
const queryContext = StubQueryContextProvider.getQueryContext();
|
||||||
|
|
||||||
const databaseAdapter = new StubDatabaseAdapter<TestFields>(
|
const databaseAdapter = new StubDatabaseAdapter<TestFields>(
|
||||||
@@ -147,7 +148,7 @@ describe(EntityLoader, () => {
|
|||||||
databaseAdapter,
|
databaseAdapter,
|
||||||
entityCache,
|
entityCache,
|
||||||
StubQueryContextProvider,
|
StubQueryContextProvider,
|
||||||
instance(mock<IEntityMetricsAdapter>()),
|
metricsAdapter,
|
||||||
TestEntity.name
|
TestEntity.name
|
||||||
);
|
);
|
||||||
const entityLoader = new EntityLoader(
|
const entityLoader = new EntityLoader(
|
||||||
@@ -156,7 +157,8 @@ describe(EntityLoader, () => {
|
|||||||
testEntityConfiguration,
|
testEntityConfiguration,
|
||||||
TestEntity,
|
TestEntity,
|
||||||
privacyPolicy,
|
privacyPolicy,
|
||||||
dataManager
|
dataManager,
|
||||||
|
metricsAdapter
|
||||||
);
|
);
|
||||||
|
|
||||||
let capturedThrownThing1: any;
|
let capturedThrownThing1: any;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ describe(EntityLoader, () => {
|
|||||||
it('loads entities', async () => {
|
it('loads entities', async () => {
|
||||||
const dateToInsert = new Date();
|
const dateToInsert = new Date();
|
||||||
const viewerContext = instance(mock(ViewerContext));
|
const viewerContext = instance(mock(ViewerContext));
|
||||||
|
const metricsAdapter = instance(mock<IEntityMetricsAdapter>());
|
||||||
const queryContext = StubQueryContextProvider.getQueryContext();
|
const queryContext = StubQueryContextProvider.getQueryContext();
|
||||||
|
|
||||||
const id1 = uuidv4();
|
const id1 = uuidv4();
|
||||||
@@ -71,7 +72,8 @@ describe(EntityLoader, () => {
|
|||||||
testEntityConfiguration,
|
testEntityConfiguration,
|
||||||
TestEntity,
|
TestEntity,
|
||||||
privacyPolicy,
|
privacyPolicy,
|
||||||
dataManager
|
dataManager,
|
||||||
|
metricsAdapter
|
||||||
);
|
);
|
||||||
const entity = await enforceAsyncResult(entityLoader.loadByIDAsync(id1));
|
const entity = await enforceAsyncResult(entityLoader.loadByIDAsync(id1));
|
||||||
expect(entity.getID()).toEqual(id1);
|
expect(entity.getID()).toEqual(id1);
|
||||||
@@ -111,6 +113,7 @@ describe(EntityLoader, () => {
|
|||||||
const privacyPolicy = new TestEntityPrivacyPolicy();
|
const privacyPolicy = new TestEntityPrivacyPolicy();
|
||||||
const spiedPrivacyPolicy = spy(privacyPolicy);
|
const spiedPrivacyPolicy = spy(privacyPolicy);
|
||||||
const viewerContext = instance(mock(ViewerContext));
|
const viewerContext = instance(mock(ViewerContext));
|
||||||
|
const metricsAdapter = instance(mock<IEntityMetricsAdapter>());
|
||||||
const queryContext = StubQueryContextProvider.getQueryContext();
|
const queryContext = StubQueryContextProvider.getQueryContext();
|
||||||
|
|
||||||
const id1 = uuidv4();
|
const id1 = uuidv4();
|
||||||
@@ -169,7 +172,8 @@ describe(EntityLoader, () => {
|
|||||||
testEntityConfiguration,
|
testEntityConfiguration,
|
||||||
TestEntity,
|
TestEntity,
|
||||||
privacyPolicy,
|
privacyPolicy,
|
||||||
dataManager
|
dataManager,
|
||||||
|
metricsAdapter
|
||||||
);
|
);
|
||||||
const entities = await enforceResultsAsync(
|
const entities = await enforceResultsAsync(
|
||||||
entityLoader.loadManyByFieldEqualityConjunctionAsync([
|
entityLoader.loadManyByFieldEqualityConjunctionAsync([
|
||||||
@@ -185,7 +189,12 @@ describe(EntityLoader, () => {
|
|||||||
);
|
);
|
||||||
expect(entities).toHaveLength(2);
|
expect(entities).toHaveLength(2);
|
||||||
verify(
|
verify(
|
||||||
spiedPrivacyPolicy.authorizeReadAsync(viewerContext, queryContext, anyOfClass(TestEntity))
|
spiedPrivacyPolicy.authorizeReadAsync(
|
||||||
|
viewerContext,
|
||||||
|
queryContext,
|
||||||
|
anyOfClass(TestEntity),
|
||||||
|
anything()
|
||||||
|
)
|
||||||
).twice();
|
).twice();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -200,6 +209,7 @@ describe(EntityLoader, () => {
|
|||||||
const spiedPrivacyPolicy = spy(privacyPolicy);
|
const spiedPrivacyPolicy = spy(privacyPolicy);
|
||||||
|
|
||||||
const viewerContext = instance(mock(ViewerContext));
|
const viewerContext = instance(mock(ViewerContext));
|
||||||
|
const metricsAdapter = instance(mock<IEntityMetricsAdapter>());
|
||||||
const queryContext = StubQueryContextProvider.getQueryContext();
|
const queryContext = StubQueryContextProvider.getQueryContext();
|
||||||
|
|
||||||
const id1 = uuidv4();
|
const id1 = uuidv4();
|
||||||
@@ -240,14 +250,18 @@ describe(EntityLoader, () => {
|
|||||||
testEntityConfiguration,
|
testEntityConfiguration,
|
||||||
TestEntity,
|
TestEntity,
|
||||||
privacyPolicy,
|
privacyPolicy,
|
||||||
dataManager
|
dataManager,
|
||||||
|
metricsAdapter
|
||||||
);
|
);
|
||||||
const entity = await enforceAsyncResult(entityLoader.loadByIDAsync(id1));
|
const entity = await enforceAsyncResult(entityLoader.loadByIDAsync(id1));
|
||||||
verify(spiedPrivacyPolicy.authorizeReadAsync(viewerContext, queryContext, entity)).once();
|
verify(
|
||||||
|
spiedPrivacyPolicy.authorizeReadAsync(viewerContext, queryContext, entity, anything())
|
||||||
|
).once();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('invalidates upon invalidate one', async () => {
|
it('invalidates upon invalidate one', async () => {
|
||||||
const viewerContext = instance(mock(ViewerContext));
|
const viewerContext = instance(mock(ViewerContext));
|
||||||
|
const metricsAdapter = instance(mock<IEntityMetricsAdapter>());
|
||||||
const queryContext = StubQueryContextProvider.getQueryContext();
|
const queryContext = StubQueryContextProvider.getQueryContext();
|
||||||
const privacyPolicy = instance(mock(TestEntityPrivacyPolicy));
|
const privacyPolicy = instance(mock(TestEntityPrivacyPolicy));
|
||||||
const dataManagerMock = mock<EntityDataManager<TestFields>>();
|
const dataManagerMock = mock<EntityDataManager<TestFields>>();
|
||||||
@@ -260,7 +274,8 @@ describe(EntityLoader, () => {
|
|||||||
testEntityConfiguration,
|
testEntityConfiguration,
|
||||||
TestEntity,
|
TestEntity,
|
||||||
privacyPolicy,
|
privacyPolicy,
|
||||||
dataManagerInstance
|
dataManagerInstance,
|
||||||
|
metricsAdapter
|
||||||
);
|
);
|
||||||
await entityLoader.invalidateFieldsAsync({ customIdField: id1 } as any);
|
await entityLoader.invalidateFieldsAsync({ customIdField: id1 } as any);
|
||||||
|
|
||||||
@@ -271,6 +286,7 @@ describe(EntityLoader, () => {
|
|||||||
|
|
||||||
it('invalidates upon invalidate by field', async () => {
|
it('invalidates upon invalidate by field', async () => {
|
||||||
const viewerContext = instance(mock(ViewerContext));
|
const viewerContext = instance(mock(ViewerContext));
|
||||||
|
const metricsAdapter = instance(mock<IEntityMetricsAdapter>());
|
||||||
const queryContext = StubQueryContextProvider.getQueryContext();
|
const queryContext = StubQueryContextProvider.getQueryContext();
|
||||||
const privacyPolicy = instance(mock(TestEntityPrivacyPolicy));
|
const privacyPolicy = instance(mock(TestEntityPrivacyPolicy));
|
||||||
const dataManagerMock = mock<EntityDataManager<TestFields>>();
|
const dataManagerMock = mock<EntityDataManager<TestFields>>();
|
||||||
@@ -283,7 +299,8 @@ describe(EntityLoader, () => {
|
|||||||
testEntityConfiguration,
|
testEntityConfiguration,
|
||||||
TestEntity,
|
TestEntity,
|
||||||
privacyPolicy,
|
privacyPolicy,
|
||||||
dataManagerInstance
|
dataManagerInstance,
|
||||||
|
metricsAdapter
|
||||||
);
|
);
|
||||||
await entityLoader.invalidateFieldsAsync({ customIdField: id1 } as any);
|
await entityLoader.invalidateFieldsAsync({ customIdField: id1 } as any);
|
||||||
verify(
|
verify(
|
||||||
@@ -293,6 +310,7 @@ describe(EntityLoader, () => {
|
|||||||
|
|
||||||
it('invalidates upon invalidate by entity', async () => {
|
it('invalidates upon invalidate by entity', async () => {
|
||||||
const viewerContext = instance(mock(ViewerContext));
|
const viewerContext = instance(mock(ViewerContext));
|
||||||
|
const metricsAdapter = instance(mock<IEntityMetricsAdapter>());
|
||||||
const queryContext = StubQueryContextProvider.getQueryContext();
|
const queryContext = StubQueryContextProvider.getQueryContext();
|
||||||
const privacyPolicy = instance(mock(TestEntityPrivacyPolicy));
|
const privacyPolicy = instance(mock(TestEntityPrivacyPolicy));
|
||||||
const dataManagerMock = mock<EntityDataManager<TestFields>>();
|
const dataManagerMock = mock<EntityDataManager<TestFields>>();
|
||||||
@@ -309,7 +327,8 @@ describe(EntityLoader, () => {
|
|||||||
testEntityConfiguration,
|
testEntityConfiguration,
|
||||||
TestEntity,
|
TestEntity,
|
||||||
privacyPolicy,
|
privacyPolicy,
|
||||||
dataManagerInstance
|
dataManagerInstance,
|
||||||
|
metricsAdapter
|
||||||
);
|
);
|
||||||
await entityLoader.invalidateEntityAsync(entityInstance);
|
await entityLoader.invalidateEntityAsync(entityInstance);
|
||||||
verify(
|
verify(
|
||||||
@@ -319,6 +338,7 @@ describe(EntityLoader, () => {
|
|||||||
|
|
||||||
it('returns error result when not allowed', async () => {
|
it('returns error result when not allowed', async () => {
|
||||||
const viewerContext = instance(mock(ViewerContext));
|
const viewerContext = instance(mock(ViewerContext));
|
||||||
|
const metricsAdapter = instance(mock<IEntityMetricsAdapter>());
|
||||||
const queryContext = StubQueryContextProvider.getQueryContext();
|
const queryContext = StubQueryContextProvider.getQueryContext();
|
||||||
const privacyPolicyMock = mock(TestEntityPrivacyPolicy);
|
const privacyPolicyMock = mock(TestEntityPrivacyPolicy);
|
||||||
const dataManagerMock = mock<EntityDataManager<TestFields>>();
|
const dataManagerMock = mock<EntityDataManager<TestFields>>();
|
||||||
@@ -331,7 +351,12 @@ describe(EntityLoader, () => {
|
|||||||
const rejectionError = new Error();
|
const rejectionError = new Error();
|
||||||
|
|
||||||
when(
|
when(
|
||||||
privacyPolicyMock.authorizeReadAsync(viewerContext, queryContext, anyOfClass(TestEntity))
|
privacyPolicyMock.authorizeReadAsync(
|
||||||
|
viewerContext,
|
||||||
|
queryContext,
|
||||||
|
anyOfClass(TestEntity),
|
||||||
|
anything()
|
||||||
|
)
|
||||||
).thenReject(rejectionError);
|
).thenReject(rejectionError);
|
||||||
|
|
||||||
const privacyPolicy = instance(privacyPolicyMock);
|
const privacyPolicy = instance(privacyPolicyMock);
|
||||||
@@ -343,7 +368,8 @@ describe(EntityLoader, () => {
|
|||||||
testEntityConfiguration,
|
testEntityConfiguration,
|
||||||
TestEntity,
|
TestEntity,
|
||||||
privacyPolicy,
|
privacyPolicy,
|
||||||
dataManagerInstance
|
dataManagerInstance,
|
||||||
|
metricsAdapter
|
||||||
);
|
);
|
||||||
|
|
||||||
const entityResult = await entityLoader.loadByIDAsync(id1);
|
const entityResult = await entityLoader.loadByIDAsync(id1);
|
||||||
@@ -354,6 +380,7 @@ describe(EntityLoader, () => {
|
|||||||
|
|
||||||
it('throws upon database adapter error', async () => {
|
it('throws upon database adapter error', async () => {
|
||||||
const viewerContext = instance(mock(ViewerContext));
|
const viewerContext = instance(mock(ViewerContext));
|
||||||
|
const metricsAdapter = instance(mock<IEntityMetricsAdapter>());
|
||||||
const queryContext = StubQueryContextProvider.getQueryContext();
|
const queryContext = StubQueryContextProvider.getQueryContext();
|
||||||
const privacyPolicy = instance(mock(TestEntityPrivacyPolicy));
|
const privacyPolicy = instance(mock(TestEntityPrivacyPolicy));
|
||||||
const dataManagerMock = mock<EntityDataManager<TestFields>>();
|
const dataManagerMock = mock<EntityDataManager<TestFields>>();
|
||||||
@@ -372,7 +399,8 @@ describe(EntityLoader, () => {
|
|||||||
testEntityConfiguration,
|
testEntityConfiguration,
|
||||||
TestEntity,
|
TestEntity,
|
||||||
privacyPolicy,
|
privacyPolicy,
|
||||||
dataManagerInstance
|
dataManagerInstance,
|
||||||
|
metricsAdapter
|
||||||
);
|
);
|
||||||
|
|
||||||
const loadByValue = uuidv4();
|
const loadByValue = uuidv4();
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import Entity from '../Entity';
|
||||||
|
import { EntityCompanionDefinition } from '../EntityCompanionProvider';
|
||||||
|
import EntityConfiguration from '../EntityConfiguration';
|
||||||
|
import { UUIDField } from '../EntityFields';
|
||||||
|
import { EntityMutationType, EntityTriggerMutationInfo } from '../EntityMutationInfo';
|
||||||
|
import { EntityNonTransactionalMutationTrigger } from '../EntityMutationTriggerConfiguration';
|
||||||
|
import EntityPrivacyPolicy from '../EntityPrivacyPolicy';
|
||||||
|
import ViewerContext from '../ViewerContext';
|
||||||
|
import AlwaysAllowPrivacyPolicyRule from '../rules/AlwaysAllowPrivacyPolicyRule';
|
||||||
|
import { createUnitTestEntityCompanionProvider } from '../utils/testing/createUnitTestEntityCompanionProvider';
|
||||||
|
|
||||||
|
type BlahFields = {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
class BlahEntityPrivacyPolicy extends EntityPrivacyPolicy<
|
||||||
|
BlahFields,
|
||||||
|
string,
|
||||||
|
ViewerContext,
|
||||||
|
BlahEntity
|
||||||
|
> {
|
||||||
|
protected override readonly createRules = [
|
||||||
|
new AlwaysAllowPrivacyPolicyRule<BlahFields, string, ViewerContext, BlahEntity>(),
|
||||||
|
];
|
||||||
|
protected override readonly readRules = [
|
||||||
|
new AlwaysAllowPrivacyPolicyRule<BlahFields, string, ViewerContext, BlahEntity>(),
|
||||||
|
];
|
||||||
|
protected override readonly updateRules = [
|
||||||
|
new AlwaysAllowPrivacyPolicyRule<BlahFields, string, ViewerContext, BlahEntity>(),
|
||||||
|
];
|
||||||
|
protected override readonly deleteRules = [
|
||||||
|
new AlwaysAllowPrivacyPolicyRule<BlahFields, string, ViewerContext, BlahEntity>(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
class BlahEntity extends Entity<BlahFields, string, ViewerContext> {
|
||||||
|
static getCompanionDefinition(): EntityCompanionDefinition<
|
||||||
|
BlahFields,
|
||||||
|
string,
|
||||||
|
ViewerContext,
|
||||||
|
BlahEntity,
|
||||||
|
BlahEntityPrivacyPolicy
|
||||||
|
> {
|
||||||
|
return blahCompanion;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const blahCompanion = new EntityCompanionDefinition({
|
||||||
|
entityClass: BlahEntity,
|
||||||
|
entityConfiguration: new EntityConfiguration<BlahFields>({
|
||||||
|
idField: 'id',
|
||||||
|
tableName: 'blah_table',
|
||||||
|
schema: {
|
||||||
|
id: new UUIDField({
|
||||||
|
columnName: 'id',
|
||||||
|
cache: true,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
databaseAdapterFlavor: 'postgres',
|
||||||
|
cacheAdapterFlavor: 'redis',
|
||||||
|
}),
|
||||||
|
privacyPolicyClass: BlahEntityPrivacyPolicy,
|
||||||
|
mutationTriggers: () => ({
|
||||||
|
afterCommit: [new TestNonTransactionalMutationTrigger()],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
class TestNonTransactionalMutationTrigger extends EntityNonTransactionalMutationTrigger<
|
||||||
|
BlahFields,
|
||||||
|
string,
|
||||||
|
ViewerContext,
|
||||||
|
BlahEntity
|
||||||
|
> {
|
||||||
|
async executeAsync(
|
||||||
|
viewerContext: ViewerContext,
|
||||||
|
entity: BlahEntity,
|
||||||
|
mutationInfo: EntityTriggerMutationInfo<BlahFields, string, ViewerContext, BlahEntity>
|
||||||
|
): Promise<void> {
|
||||||
|
if (mutationInfo.type === EntityMutationType.DELETE) {
|
||||||
|
const entityLoaded = await BlahEntity.loader(viewerContext)
|
||||||
|
.enforcing()
|
||||||
|
.loadByIDNullableAsync(entity.getID());
|
||||||
|
if (entityLoaded) {
|
||||||
|
throw new Error(
|
||||||
|
'should not have been able to re-load the entity after delete. this means the cache has not been cleared'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('EntityMutator', () => {
|
||||||
|
test('cache consistency with post-commit callbacks', async () => {
|
||||||
|
const companionProvider = createUnitTestEntityCompanionProvider();
|
||||||
|
const viewerContext = new ViewerContext(companionProvider);
|
||||||
|
|
||||||
|
// put it in cache
|
||||||
|
const entity = await BlahEntity.creator(viewerContext).enforceCreateAsync();
|
||||||
|
const entityLoaded = await BlahEntity.loader(viewerContext)
|
||||||
|
.enforcing()
|
||||||
|
.loadByIDAsync(entity.getID());
|
||||||
|
|
||||||
|
await BlahEntity.enforceDeleteAsync(entityLoaded);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,12 +14,16 @@ import { v4 as uuidv4 } from 'uuid';
|
|||||||
|
|
||||||
import EntityDatabaseAdapter from '../EntityDatabaseAdapter';
|
import EntityDatabaseAdapter from '../EntityDatabaseAdapter';
|
||||||
import EntityLoaderFactory from '../EntityLoaderFactory';
|
import EntityLoaderFactory from '../EntityLoaderFactory';
|
||||||
|
import {
|
||||||
|
EntityMutationType,
|
||||||
|
EntityTriggerMutationInfo,
|
||||||
|
EntityValidatorMutationInfo,
|
||||||
|
} from '../EntityMutationInfo';
|
||||||
import EntityMutationTriggerConfiguration, {
|
import EntityMutationTriggerConfiguration, {
|
||||||
EntityMutationTrigger,
|
EntityMutationTrigger,
|
||||||
EntityNonTransactionalMutationTrigger,
|
EntityNonTransactionalMutationTrigger,
|
||||||
} from '../EntityMutationTriggerConfiguration';
|
} from '../EntityMutationTriggerConfiguration';
|
||||||
import EntityMutationValidator from '../EntityMutationValidator';
|
import EntityMutationValidator from '../EntityMutationValidator';
|
||||||
import { EntityMutationInfo, EntityMutationType } from '../EntityMutator';
|
|
||||||
import EntityMutatorFactory from '../EntityMutatorFactory';
|
import EntityMutatorFactory from '../EntityMutatorFactory';
|
||||||
import { EntityTransactionalQueryContext, EntityQueryContext } from '../EntityQueryContext';
|
import { EntityTransactionalQueryContext, EntityQueryContext } from '../EntityQueryContext';
|
||||||
import ViewerContext from '../ViewerContext';
|
import ViewerContext from '../ViewerContext';
|
||||||
@@ -53,7 +57,7 @@ class TestMutationTrigger extends EntityMutationTrigger<
|
|||||||
_viewerContext: ViewerContext,
|
_viewerContext: ViewerContext,
|
||||||
_queryContext: EntityQueryContext,
|
_queryContext: EntityQueryContext,
|
||||||
_entity: TestEntity,
|
_entity: TestEntity,
|
||||||
_mutationInfo: EntityMutationInfo<
|
_mutationInfo: EntityTriggerMutationInfo<
|
||||||
TestFields,
|
TestFields,
|
||||||
string,
|
string,
|
||||||
ViewerContext,
|
ViewerContext,
|
||||||
@@ -95,7 +99,13 @@ const verifyValidatorCounts = (
|
|||||||
keyof TestFields
|
keyof TestFields
|
||||||
>[],
|
>[],
|
||||||
expectedCalls: number,
|
expectedCalls: number,
|
||||||
mutationInfo: EntityMutationInfo<TestFields, string, ViewerContext, TestEntity, keyof TestFields>
|
mutationInfo: EntityValidatorMutationInfo<
|
||||||
|
TestFields,
|
||||||
|
string,
|
||||||
|
ViewerContext,
|
||||||
|
TestEntity,
|
||||||
|
keyof TestFields
|
||||||
|
>
|
||||||
): void => {
|
): void => {
|
||||||
for (const validator of mutationValidatorSpies) {
|
for (const validator of mutationValidatorSpies) {
|
||||||
verify(
|
verify(
|
||||||
@@ -164,7 +174,13 @@ const verifyTriggerCounts = (
|
|||||||
>,
|
>,
|
||||||
boolean
|
boolean
|
||||||
>,
|
>,
|
||||||
mutationInfo: EntityMutationInfo<TestFields, string, ViewerContext, TestEntity, keyof TestFields>
|
mutationInfo: EntityTriggerMutationInfo<
|
||||||
|
TestFields,
|
||||||
|
string,
|
||||||
|
ViewerContext,
|
||||||
|
TestEntity,
|
||||||
|
keyof TestFields
|
||||||
|
>
|
||||||
): void => {
|
): void => {
|
||||||
Object.keys(executed).forEach((s) => {
|
Object.keys(executed).forEach((s) => {
|
||||||
if ((executed as any)[s]) {
|
if ((executed as any)[s]) {
|
||||||
@@ -297,7 +313,8 @@ const createEntityMutatorFactory = (
|
|||||||
testEntityConfiguration,
|
testEntityConfiguration,
|
||||||
TestEntity,
|
TestEntity,
|
||||||
privacyPolicy,
|
privacyPolicy,
|
||||||
dataManager
|
dataManager,
|
||||||
|
metricsAdapter
|
||||||
);
|
);
|
||||||
const entityMutatorFactory = new EntityMutatorFactory(
|
const entityMutatorFactory = new EntityMutatorFactory(
|
||||||
testEntityConfiguration,
|
testEntityConfiguration,
|
||||||
@@ -388,7 +405,8 @@ describe(EntityMutatorFactory, () => {
|
|||||||
spiedPrivacyPolicy.authorizeCreateAsync(
|
spiedPrivacyPolicy.authorizeCreateAsync(
|
||||||
viewerContext,
|
viewerContext,
|
||||||
anyOfClass(EntityTransactionalQueryContext),
|
anyOfClass(EntityTransactionalQueryContext),
|
||||||
anyOfClass(TestEntity)
|
anyOfClass(TestEntity),
|
||||||
|
anything()
|
||||||
)
|
)
|
||||||
).once();
|
).once();
|
||||||
});
|
});
|
||||||
@@ -562,7 +580,8 @@ describe(EntityMutatorFactory, () => {
|
|||||||
spiedPrivacyPolicy.authorizeUpdateAsync(
|
spiedPrivacyPolicy.authorizeUpdateAsync(
|
||||||
viewerContext,
|
viewerContext,
|
||||||
anyOfClass(EntityTransactionalQueryContext),
|
anyOfClass(EntityTransactionalQueryContext),
|
||||||
anyOfClass(TestEntity)
|
anyOfClass(TestEntity),
|
||||||
|
anything()
|
||||||
)
|
)
|
||||||
).once();
|
).once();
|
||||||
});
|
});
|
||||||
@@ -722,7 +741,8 @@ describe(EntityMutatorFactory, () => {
|
|||||||
spiedPrivacyPolicy.authorizeDeleteAsync(
|
spiedPrivacyPolicy.authorizeDeleteAsync(
|
||||||
viewerContext,
|
viewerContext,
|
||||||
anyOfClass(EntityTransactionalQueryContext),
|
anyOfClass(EntityTransactionalQueryContext),
|
||||||
anyOfClass(TestEntity)
|
anyOfClass(TestEntity),
|
||||||
|
anything()
|
||||||
)
|
)
|
||||||
).once();
|
).once();
|
||||||
});
|
});
|
||||||
@@ -763,7 +783,7 @@ describe(EntityMutatorFactory, () => {
|
|||||||
beforeDelete: true,
|
beforeDelete: true,
|
||||||
afterDelete: true,
|
afterDelete: true,
|
||||||
},
|
},
|
||||||
{ type: EntityMutationType.DELETE }
|
{ type: EntityMutationType.DELETE, cascadingDeleteCause: null }
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -792,7 +812,9 @@ describe(EntityMutatorFactory, () => {
|
|||||||
|
|
||||||
await entityMutatorFactory.forDelete(existingEntity, queryContext).enforceDeleteAsync();
|
await entityMutatorFactory.forDelete(existingEntity, queryContext).enforceDeleteAsync();
|
||||||
|
|
||||||
verifyValidatorCounts(viewerContext, validatorSpies, 0, { type: EntityMutationType.DELETE });
|
verifyValidatorCounts(viewerContext, validatorSpies, 0, {
|
||||||
|
type: EntityMutationType.DELETE as any,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -894,21 +916,24 @@ describe(EntityMutatorFactory, () => {
|
|||||||
privacyPolicyMock.authorizeCreateAsync(
|
privacyPolicyMock.authorizeCreateAsync(
|
||||||
viewerContext,
|
viewerContext,
|
||||||
anyOfClass(EntityTransactionalQueryContext),
|
anyOfClass(EntityTransactionalQueryContext),
|
||||||
anyOfClass(SimpleTestEntity)
|
anyOfClass(SimpleTestEntity),
|
||||||
|
anything()
|
||||||
)
|
)
|
||||||
).thenReject(rejectionError);
|
).thenReject(rejectionError);
|
||||||
when(
|
when(
|
||||||
privacyPolicyMock.authorizeUpdateAsync(
|
privacyPolicyMock.authorizeUpdateAsync(
|
||||||
viewerContext,
|
viewerContext,
|
||||||
anyOfClass(EntityTransactionalQueryContext),
|
anyOfClass(EntityTransactionalQueryContext),
|
||||||
anyOfClass(SimpleTestEntity)
|
anyOfClass(SimpleTestEntity),
|
||||||
|
anything()
|
||||||
)
|
)
|
||||||
).thenReject(rejectionError);
|
).thenReject(rejectionError);
|
||||||
when(
|
when(
|
||||||
privacyPolicyMock.authorizeDeleteAsync(
|
privacyPolicyMock.authorizeDeleteAsync(
|
||||||
viewerContext,
|
viewerContext,
|
||||||
anyOfClass(EntityTransactionalQueryContext),
|
anyOfClass(EntityTransactionalQueryContext),
|
||||||
anyOfClass(SimpleTestEntity)
|
anyOfClass(SimpleTestEntity),
|
||||||
|
anything()
|
||||||
)
|
)
|
||||||
).thenReject(rejectionError);
|
).thenReject(rejectionError);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { mock, instance, spy, verify, anyOfClass } from 'ts-mockito';
|
import { mock, instance, spy, verify, anyOfClass, anything, objectContaining } from 'ts-mockito';
|
||||||
|
|
||||||
import Entity from '../Entity';
|
import Entity from '../Entity';
|
||||||
import { EntityCompanionDefinition } from '../EntityCompanionProvider';
|
import { EntityCompanionDefinition } from '../EntityCompanionProvider';
|
||||||
@@ -6,11 +6,15 @@ import EntityConfiguration from '../EntityConfiguration';
|
|||||||
import { UUIDField } from '../EntityFields';
|
import { UUIDField } from '../EntityFields';
|
||||||
import EntityPrivacyPolicy, {
|
import EntityPrivacyPolicy, {
|
||||||
EntityPrivacyPolicyEvaluator,
|
EntityPrivacyPolicyEvaluator,
|
||||||
|
EntityAuthorizationAction,
|
||||||
EntityPrivacyPolicyEvaluationMode,
|
EntityPrivacyPolicyEvaluationMode,
|
||||||
} from '../EntityPrivacyPolicy';
|
} from '../EntityPrivacyPolicy';
|
||||||
import { EntityQueryContext } from '../EntityQueryContext';
|
import { EntityQueryContext } from '../EntityQueryContext';
|
||||||
import ViewerContext from '../ViewerContext';
|
import ViewerContext from '../ViewerContext';
|
||||||
import EntityNotAuthorizedError from '../errors/EntityNotAuthorizedError';
|
import EntityNotAuthorizedError from '../errors/EntityNotAuthorizedError';
|
||||||
|
import IEntityMetricsAdapter, {
|
||||||
|
EntityMetricsAuthorizationResult,
|
||||||
|
} from '../metrics/IEntityMetricsAdapter';
|
||||||
import AlwaysAllowPrivacyPolicyRule from '../rules/AlwaysAllowPrivacyPolicyRule';
|
import AlwaysAllowPrivacyPolicyRule from '../rules/AlwaysAllowPrivacyPolicyRule';
|
||||||
import AlwaysDenyPrivacyPolicyRule from '../rules/AlwaysDenyPrivacyPolicyRule';
|
import AlwaysDenyPrivacyPolicyRule from '../rules/AlwaysDenyPrivacyPolicyRule';
|
||||||
import AlwaysSkipPrivacyPolicyRule from '../rules/AlwaysSkipPrivacyPolicyRule';
|
import AlwaysSkipPrivacyPolicyRule from '../rules/AlwaysSkipPrivacyPolicyRule';
|
||||||
@@ -237,143 +241,276 @@ const blahEntityCompanionDefinition = new EntityCompanionDefinition({
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe(EntityPrivacyPolicy, () => {
|
describe(EntityPrivacyPolicy, () => {
|
||||||
it('throws EntityNotAuthorizedError when deny', async () => {
|
describe(EntityPrivacyPolicyEvaluationMode.ENFORCE.toString(), () => {
|
||||||
const viewerContext = instance(mock(ViewerContext));
|
it('throws EntityNotAuthorizedError when deny', async () => {
|
||||||
const queryContext = instance(mock(EntityQueryContext));
|
const viewerContext = instance(mock(ViewerContext));
|
||||||
const entity = new BlahEntity(viewerContext, { id: '1' });
|
const queryContext = instance(mock(EntityQueryContext));
|
||||||
const policy = new AlwaysDenyPolicy();
|
const metricsAdapterMock = mock<IEntityMetricsAdapter>();
|
||||||
await expect(
|
const metricsAdapter = instance(metricsAdapterMock);
|
||||||
policy.authorizeCreateAsync(viewerContext, queryContext, entity)
|
const entity = new BlahEntity(viewerContext, { id: '1' });
|
||||||
).rejects.toBeInstanceOf(EntityNotAuthorizedError);
|
const policy = new AlwaysDenyPolicy();
|
||||||
|
await expect(
|
||||||
|
policy.authorizeCreateAsync(viewerContext, queryContext, entity, metricsAdapter)
|
||||||
|
).rejects.toBeInstanceOf(EntityNotAuthorizedError);
|
||||||
|
verify(
|
||||||
|
metricsAdapterMock.logAuthorizationEvent(
|
||||||
|
objectContaining({
|
||||||
|
entityClassName: entity.constructor.name,
|
||||||
|
action: EntityAuthorizationAction.CREATE,
|
||||||
|
evaluationResult: EntityMetricsAuthorizationResult.DENY,
|
||||||
|
privacyPolicyEvaluationMode: EntityPrivacyPolicyEvaluationMode.ENFORCE,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).once();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns entity when allowed', async () => {
|
||||||
|
const viewerContext = instance(mock(ViewerContext));
|
||||||
|
const queryContext = instance(mock(EntityQueryContext));
|
||||||
|
const metricsAdapterMock = mock<IEntityMetricsAdapter>();
|
||||||
|
const metricsAdapter = instance(metricsAdapterMock);
|
||||||
|
const entity = new BlahEntity(viewerContext, { id: '1' });
|
||||||
|
const policy = new AlwaysAllowPolicy();
|
||||||
|
const approvedEntity = await policy.authorizeCreateAsync(
|
||||||
|
viewerContext,
|
||||||
|
queryContext,
|
||||||
|
entity,
|
||||||
|
metricsAdapter
|
||||||
|
);
|
||||||
|
expect(approvedEntity).toEqual(entity);
|
||||||
|
verify(
|
||||||
|
metricsAdapterMock.logAuthorizationEvent(
|
||||||
|
objectContaining({
|
||||||
|
entityClassName: entity.constructor.name,
|
||||||
|
action: EntityAuthorizationAction.CREATE,
|
||||||
|
evaluationResult: EntityMetricsAuthorizationResult.ALLOW,
|
||||||
|
privacyPolicyEvaluationMode: EntityPrivacyPolicyEvaluationMode.ENFORCE,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).once();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws EntityNotAuthorizedError when all skipped', async () => {
|
||||||
|
const viewerContext = instance(mock(ViewerContext));
|
||||||
|
const queryContext = instance(mock(EntityQueryContext));
|
||||||
|
const metricsAdapterMock = mock<IEntityMetricsAdapter>();
|
||||||
|
const metricsAdapter = instance(metricsAdapterMock);
|
||||||
|
const entity = new BlahEntity(viewerContext, { id: '1' });
|
||||||
|
const policy = new SkipAllPolicy();
|
||||||
|
await expect(
|
||||||
|
policy.authorizeCreateAsync(viewerContext, queryContext, entity, metricsAdapter)
|
||||||
|
).rejects.toBeInstanceOf(EntityNotAuthorizedError);
|
||||||
|
verify(
|
||||||
|
metricsAdapterMock.logAuthorizationEvent(
|
||||||
|
objectContaining({
|
||||||
|
entityClassName: entity.constructor.name,
|
||||||
|
action: EntityAuthorizationAction.CREATE,
|
||||||
|
evaluationResult: EntityMetricsAuthorizationResult.DENY,
|
||||||
|
privacyPolicyEvaluationMode: EntityPrivacyPolicyEvaluationMode.ENFORCE,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).once();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws EntityNotAuthorizedError when empty policy', async () => {
|
||||||
|
const viewerContext = instance(mock(ViewerContext));
|
||||||
|
const queryContext = instance(mock(EntityQueryContext));
|
||||||
|
const metricsAdapterMock = mock<IEntityMetricsAdapter>();
|
||||||
|
const metricsAdapter = instance(metricsAdapterMock);
|
||||||
|
const entity = new BlahEntity(viewerContext, { id: '1' });
|
||||||
|
const policy = new EmptyPolicy();
|
||||||
|
await expect(
|
||||||
|
policy.authorizeCreateAsync(viewerContext, queryContext, entity, metricsAdapter)
|
||||||
|
).rejects.toBeInstanceOf(EntityNotAuthorizedError);
|
||||||
|
verify(
|
||||||
|
metricsAdapterMock.logAuthorizationEvent(
|
||||||
|
objectContaining({
|
||||||
|
entityClassName: entity.constructor.name,
|
||||||
|
action: EntityAuthorizationAction.CREATE,
|
||||||
|
evaluationResult: EntityMetricsAuthorizationResult.DENY,
|
||||||
|
privacyPolicyEvaluationMode: EntityPrivacyPolicyEvaluationMode.ENFORCE,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).once();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when rule throws', async () => {
|
||||||
|
const viewerContext = instance(mock(ViewerContext));
|
||||||
|
const queryContext = instance(mock(EntityQueryContext));
|
||||||
|
const metricsAdapterMock = mock<IEntityMetricsAdapter>();
|
||||||
|
const metricsAdapter = instance(metricsAdapterMock);
|
||||||
|
const entity = new BlahEntity(viewerContext, { id: '1' });
|
||||||
|
const policy = new ThrowAllPolicy();
|
||||||
|
await expect(
|
||||||
|
policy.authorizeCreateAsync(viewerContext, queryContext, entity, metricsAdapter)
|
||||||
|
).rejects.toThrowError('WooHoo!');
|
||||||
|
verify(metricsAdapterMock.logAuthorizationEvent(anything())).never();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns entity when allowed', async () => {
|
describe(EntityPrivacyPolicyEvaluationMode.DRY_RUN.toString(), () => {
|
||||||
const viewerContext = instance(mock(ViewerContext));
|
|
||||||
const queryContext = instance(mock(EntityQueryContext));
|
|
||||||
const entity = new BlahEntity(viewerContext, { id: '1' });
|
|
||||||
const policy = new AlwaysAllowPolicy();
|
|
||||||
const approvedEntity = await policy.authorizeCreateAsync(viewerContext, queryContext, entity);
|
|
||||||
expect(approvedEntity).toEqual(entity);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws EntityNotAuthorizedError when all skipped', async () => {
|
|
||||||
const viewerContext = instance(mock(ViewerContext));
|
|
||||||
const queryContext = instance(mock(EntityQueryContext));
|
|
||||||
const entity = new BlahEntity(viewerContext, { id: '1' });
|
|
||||||
const policy = new SkipAllPolicy();
|
|
||||||
await expect(
|
|
||||||
policy.authorizeCreateAsync(viewerContext, queryContext, entity)
|
|
||||||
).rejects.toBeInstanceOf(EntityNotAuthorizedError);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws EntityNotAuthorizedError when empty policy', async () => {
|
|
||||||
const viewerContext = instance(mock(ViewerContext));
|
|
||||||
const queryContext = instance(mock(EntityQueryContext));
|
|
||||||
const entity = new BlahEntity(viewerContext, { id: '1' });
|
|
||||||
const policy = new EmptyPolicy();
|
|
||||||
await expect(
|
|
||||||
policy.authorizeCreateAsync(viewerContext, queryContext, entity)
|
|
||||||
).rejects.toBeInstanceOf(EntityNotAuthorizedError);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws when rule throws', async () => {
|
|
||||||
const viewerContext = instance(mock(ViewerContext));
|
|
||||||
const queryContext = instance(mock(EntityQueryContext));
|
|
||||||
const entity = new BlahEntity(viewerContext, { id: '1' });
|
|
||||||
const policy = new ThrowAllPolicy();
|
|
||||||
await expect(
|
|
||||||
policy.authorizeCreateAsync(viewerContext, queryContext, entity)
|
|
||||||
).rejects.toThrowError('WooHoo!');
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('dry run', () => {
|
|
||||||
it('returns entity when denied but calls denialHandler', async () => {
|
it('returns entity when denied but calls denialHandler', async () => {
|
||||||
const viewerContext = instance(mock(ViewerContext));
|
const viewerContext = instance(mock(ViewerContext));
|
||||||
const queryContext = instance(mock(EntityQueryContext));
|
const queryContext = instance(mock(EntityQueryContext));
|
||||||
|
const metricsAdapterMock = mock<IEntityMetricsAdapter>();
|
||||||
|
const metricsAdapter = instance(metricsAdapterMock);
|
||||||
const entity = new BlahEntity(viewerContext, { id: '1' });
|
const entity = new BlahEntity(viewerContext, { id: '1' });
|
||||||
const policy = new DryRunAlwaysDenyPolicy();
|
const policy = new DryRunAlwaysDenyPolicy();
|
||||||
|
|
||||||
const policySpy = spy(policy);
|
const policySpy = spy(policy);
|
||||||
|
|
||||||
const approvedEntity = await policy.authorizeCreateAsync(viewerContext, queryContext, entity);
|
const approvedEntity = await policy.authorizeCreateAsync(
|
||||||
|
viewerContext,
|
||||||
|
queryContext,
|
||||||
|
entity,
|
||||||
|
metricsAdapter
|
||||||
|
);
|
||||||
expect(approvedEntity).toEqual(entity);
|
expect(approvedEntity).toEqual(entity);
|
||||||
|
|
||||||
verify(policySpy.denyHandler(anyOfClass(EntityNotAuthorizedError))).once();
|
verify(policySpy.denyHandler(anyOfClass(EntityNotAuthorizedError))).once();
|
||||||
|
|
||||||
|
verify(
|
||||||
|
metricsAdapterMock.logAuthorizationEvent(
|
||||||
|
objectContaining({
|
||||||
|
entityClassName: entity.constructor.name,
|
||||||
|
action: EntityAuthorizationAction.CREATE,
|
||||||
|
evaluationResult: EntityMetricsAuthorizationResult.DENY,
|
||||||
|
privacyPolicyEvaluationMode: EntityPrivacyPolicyEvaluationMode.DRY_RUN,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).once();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not log when not denied', async () => {
|
it('does not log when not denied', async () => {
|
||||||
const viewerContext = instance(mock(ViewerContext));
|
const viewerContext = instance(mock(ViewerContext));
|
||||||
const queryContext = instance(mock(EntityQueryContext));
|
const queryContext = instance(mock(EntityQueryContext));
|
||||||
|
const metricsAdapterMock = mock<IEntityMetricsAdapter>();
|
||||||
|
const metricsAdapter = instance(metricsAdapterMock);
|
||||||
const entity = new BlahEntity(viewerContext, { id: '1' });
|
const entity = new BlahEntity(viewerContext, { id: '1' });
|
||||||
const policy = new DryRunAlwaysAllowPolicy();
|
const policy = new DryRunAlwaysAllowPolicy();
|
||||||
|
|
||||||
const policySpy = spy(policy);
|
const policySpy = spy(policy);
|
||||||
|
|
||||||
const approvedEntity = await policy.authorizeCreateAsync(viewerContext, queryContext, entity);
|
const approvedEntity = await policy.authorizeCreateAsync(
|
||||||
|
viewerContext,
|
||||||
|
queryContext,
|
||||||
|
entity,
|
||||||
|
metricsAdapter
|
||||||
|
);
|
||||||
expect(approvedEntity).toEqual(entity);
|
expect(approvedEntity).toEqual(entity);
|
||||||
|
|
||||||
verify(policySpy.denyHandler(anyOfClass(EntityNotAuthorizedError))).never();
|
verify(policySpy.denyHandler(anyOfClass(EntityNotAuthorizedError))).never();
|
||||||
|
|
||||||
|
verify(
|
||||||
|
metricsAdapterMock.logAuthorizationEvent(
|
||||||
|
objectContaining({
|
||||||
|
entityClassName: entity.constructor.name,
|
||||||
|
action: EntityAuthorizationAction.CREATE,
|
||||||
|
evaluationResult: EntityMetricsAuthorizationResult.ALLOW,
|
||||||
|
privacyPolicyEvaluationMode: EntityPrivacyPolicyEvaluationMode.DRY_RUN,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).once();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('passes through other errors', async () => {
|
it('passes through other errors', async () => {
|
||||||
const viewerContext = instance(mock(ViewerContext));
|
const viewerContext = instance(mock(ViewerContext));
|
||||||
const queryContext = instance(mock(EntityQueryContext));
|
const queryContext = instance(mock(EntityQueryContext));
|
||||||
|
const metricsAdapterMock = mock<IEntityMetricsAdapter>();
|
||||||
|
const metricsAdapter = instance(metricsAdapterMock);
|
||||||
const entity = new BlahEntity(viewerContext, { id: '1' });
|
const entity = new BlahEntity(viewerContext, { id: '1' });
|
||||||
const policy = new DryRunThrowAllPolicy();
|
const policy = new DryRunThrowAllPolicy();
|
||||||
|
|
||||||
const policySpy = spy(policy);
|
const policySpy = spy(policy);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
policy.authorizeCreateAsync(viewerContext, queryContext, entity)
|
policy.authorizeCreateAsync(viewerContext, queryContext, entity, metricsAdapter)
|
||||||
).rejects.toThrowError('WooHoo!');
|
).rejects.toThrowError('WooHoo!');
|
||||||
|
|
||||||
verify(policySpy.denyHandler(anyOfClass(EntityNotAuthorizedError))).never();
|
verify(policySpy.denyHandler(anyOfClass(EntityNotAuthorizedError))).never();
|
||||||
|
|
||||||
|
verify(metricsAdapterMock.logAuthorizationEvent(anything())).never();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('logging enforce', () => {
|
describe(EntityPrivacyPolicyEvaluationMode.ENFORCE_AND_LOG.toString(), () => {
|
||||||
it('denies when denied but calls denialHandler', async () => {
|
it('denies when denied but calls denialHandler', async () => {
|
||||||
const viewerContext = instance(mock(ViewerContext));
|
const viewerContext = instance(mock(ViewerContext));
|
||||||
const queryContext = instance(mock(EntityQueryContext));
|
const queryContext = instance(mock(EntityQueryContext));
|
||||||
|
const metricsAdapterMock = mock<IEntityMetricsAdapter>();
|
||||||
|
const metricsAdapter = instance(metricsAdapterMock);
|
||||||
const entity = new BlahEntity(viewerContext, { id: '1' });
|
const entity = new BlahEntity(viewerContext, { id: '1' });
|
||||||
const policy = new LoggingEnforceAlwaysDenyPolicy();
|
const policy = new LoggingEnforceAlwaysDenyPolicy();
|
||||||
|
|
||||||
const policySpy = spy(policy);
|
const policySpy = spy(policy);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
policy.authorizeCreateAsync(viewerContext, queryContext, entity)
|
policy.authorizeCreateAsync(viewerContext, queryContext, entity, metricsAdapter)
|
||||||
).rejects.toBeInstanceOf(EntityNotAuthorizedError);
|
).rejects.toBeInstanceOf(EntityNotAuthorizedError);
|
||||||
|
|
||||||
verify(policySpy.denyHandler(anyOfClass(EntityNotAuthorizedError))).once();
|
verify(policySpy.denyHandler(anyOfClass(EntityNotAuthorizedError))).once();
|
||||||
|
|
||||||
|
verify(
|
||||||
|
metricsAdapterMock.logAuthorizationEvent(
|
||||||
|
objectContaining({
|
||||||
|
entityClassName: entity.constructor.name,
|
||||||
|
action: EntityAuthorizationAction.CREATE,
|
||||||
|
evaluationResult: EntityMetricsAuthorizationResult.DENY,
|
||||||
|
privacyPolicyEvaluationMode: EntityPrivacyPolicyEvaluationMode.ENFORCE_AND_LOG,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).once();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not log when not denied', async () => {
|
it('does not log when not denied', async () => {
|
||||||
const viewerContext = instance(mock(ViewerContext));
|
const viewerContext = instance(mock(ViewerContext));
|
||||||
const queryContext = instance(mock(EntityQueryContext));
|
const queryContext = instance(mock(EntityQueryContext));
|
||||||
|
const metricsAdapterMock = mock<IEntityMetricsAdapter>();
|
||||||
|
const metricsAdapter = instance(metricsAdapterMock);
|
||||||
const entity = new BlahEntity(viewerContext, { id: '1' });
|
const entity = new BlahEntity(viewerContext, { id: '1' });
|
||||||
const policy = new LoggingEnforceAlwaysAllowPolicy();
|
const policy = new LoggingEnforceAlwaysAllowPolicy();
|
||||||
|
|
||||||
const policySpy = spy(policy);
|
const policySpy = spy(policy);
|
||||||
|
|
||||||
const approvedEntity = await policy.authorizeCreateAsync(viewerContext, queryContext, entity);
|
const approvedEntity = await policy.authorizeCreateAsync(
|
||||||
|
viewerContext,
|
||||||
|
queryContext,
|
||||||
|
entity,
|
||||||
|
metricsAdapter
|
||||||
|
);
|
||||||
expect(approvedEntity).toEqual(entity);
|
expect(approvedEntity).toEqual(entity);
|
||||||
|
|
||||||
verify(policySpy.denyHandler(anyOfClass(EntityNotAuthorizedError))).never();
|
verify(policySpy.denyHandler(anyOfClass(EntityNotAuthorizedError))).never();
|
||||||
|
|
||||||
|
verify(
|
||||||
|
metricsAdapterMock.logAuthorizationEvent(
|
||||||
|
objectContaining({
|
||||||
|
entityClassName: entity.constructor.name,
|
||||||
|
action: EntityAuthorizationAction.CREATE,
|
||||||
|
evaluationResult: EntityMetricsAuthorizationResult.ALLOW,
|
||||||
|
privacyPolicyEvaluationMode: EntityPrivacyPolicyEvaluationMode.ENFORCE_AND_LOG,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).once();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('passes through other errors', async () => {
|
it('passes through other errors', async () => {
|
||||||
const viewerContext = instance(mock(ViewerContext));
|
const viewerContext = instance(mock(ViewerContext));
|
||||||
const queryContext = instance(mock(EntityQueryContext));
|
const queryContext = instance(mock(EntityQueryContext));
|
||||||
|
const metricsAdapterMock = mock<IEntityMetricsAdapter>();
|
||||||
|
const metricsAdapter = instance(metricsAdapterMock);
|
||||||
const entity = new BlahEntity(viewerContext, { id: '1' });
|
const entity = new BlahEntity(viewerContext, { id: '1' });
|
||||||
const policy = new LoggingEnforceThrowAllPolicy();
|
const policy = new LoggingEnforceThrowAllPolicy();
|
||||||
|
|
||||||
const policySpy = spy(policy);
|
const policySpy = spy(policy);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
policy.authorizeCreateAsync(viewerContext, queryContext, entity)
|
policy.authorizeCreateAsync(viewerContext, queryContext, entity, metricsAdapter)
|
||||||
).rejects.toThrowError('WooHoo!');
|
).rejects.toThrowError('WooHoo!');
|
||||||
|
|
||||||
verify(policySpy.denyHandler(anyOfClass(EntityNotAuthorizedError))).never();
|
verify(policySpy.denyHandler(anyOfClass(EntityNotAuthorizedError))).never();
|
||||||
|
|
||||||
|
verify(metricsAdapterMock.logAuthorizationEvent(anything())).never();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import invariant from 'invariant';
|
||||||
|
|
||||||
|
import { EntityQueryContext } from '../EntityQueryContext';
|
||||||
|
import ViewerContext from '../ViewerContext';
|
||||||
|
import { createUnitTestEntityCompanionProvider } from '../utils/testing/createUnitTestEntityCompanionProvider';
|
||||||
|
|
||||||
|
describe(EntityQueryContext, () => {
|
||||||
|
describe('callbacks', () => {
|
||||||
|
it('calls all callbacks, and calls invalidation first', async () => {
|
||||||
|
const companionProvider = createUnitTestEntityCompanionProvider();
|
||||||
|
const viewerContext = new ViewerContext(companionProvider);
|
||||||
|
|
||||||
|
const preCommitFirstCallback = jest.fn(async (): Promise<void> => {});
|
||||||
|
const preCommitSecondCallback = jest.fn(async (): Promise<void> => {});
|
||||||
|
const postCommitInvalidationCallback = jest.fn(async (): Promise<void> => {
|
||||||
|
invariant(
|
||||||
|
preCommitFirstCallback.mock.calls.length === 1,
|
||||||
|
'preCommit should be called before postCommitInvalidation'
|
||||||
|
);
|
||||||
|
invariant(
|
||||||
|
preCommitSecondCallback.mock.calls.length === 1,
|
||||||
|
'preCommit should be called before postCommitInvalidation'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const postCommitCallback = jest.fn(async (): Promise<void> => {
|
||||||
|
invariant(
|
||||||
|
preCommitFirstCallback.mock.calls.length === 1,
|
||||||
|
'preCommit should be called before postCommit'
|
||||||
|
);
|
||||||
|
invariant(
|
||||||
|
preCommitSecondCallback.mock.calls.length === 1,
|
||||||
|
'preCommit should be called before postCommit'
|
||||||
|
);
|
||||||
|
invariant(
|
||||||
|
postCommitInvalidationCallback.mock.calls.length === 1,
|
||||||
|
'postCommitInvalidation should be called before postCommit'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await viewerContext.runInTransactionForDatabaseAdaptorFlavorAsync(
|
||||||
|
'postgres',
|
||||||
|
async (queryContext) => {
|
||||||
|
queryContext.appendPostCommitCallback(postCommitCallback);
|
||||||
|
queryContext.appendPostCommitInvalidationCallback(postCommitInvalidationCallback);
|
||||||
|
queryContext.appendPreCommitCallback(preCommitSecondCallback, 2);
|
||||||
|
queryContext.appendPreCommitCallback(preCommitFirstCallback, 1);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(preCommitFirstCallback).toHaveBeenCalledTimes(1);
|
||||||
|
expect(preCommitSecondCallback).toHaveBeenCalledTimes(1);
|
||||||
|
expect(postCommitCallback).toHaveBeenCalledTimes(1);
|
||||||
|
expect(postCommitInvalidationCallback).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prevents transaction from finishing when precommit throws (post commit callbacks are not called)', async () => {
|
||||||
|
const companionProvider = createUnitTestEntityCompanionProvider();
|
||||||
|
const viewerContext = new ViewerContext(companionProvider);
|
||||||
|
|
||||||
|
const preCommitCallback = jest.fn(async (): Promise<void> => {
|
||||||
|
throw new Error('wat');
|
||||||
|
});
|
||||||
|
const postCommitInvalidationCallback = jest.fn(async (): Promise<void> => {});
|
||||||
|
const postCommitCallback = jest.fn(async (): Promise<void> => {});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
viewerContext.runInTransactionForDatabaseAdaptorFlavorAsync(
|
||||||
|
'postgres',
|
||||||
|
async (queryContext) => {
|
||||||
|
queryContext.appendPostCommitCallback(postCommitCallback);
|
||||||
|
queryContext.appendPostCommitInvalidationCallback(postCommitInvalidationCallback);
|
||||||
|
queryContext.appendPreCommitCallback(preCommitCallback, 0);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
).rejects.toThrowError('wat');
|
||||||
|
|
||||||
|
expect(preCommitCallback).toHaveBeenCalledTimes(1);
|
||||||
|
expect(postCommitCallback).toHaveBeenCalledTimes(0);
|
||||||
|
expect(postCommitInvalidationCallback).toHaveBeenCalledTimes(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -78,6 +78,7 @@ describe(EntitySecondaryCacheLoader, () => {
|
|||||||
spiedPrivacyPolicy.authorizeReadAsync(
|
spiedPrivacyPolicy.authorizeReadAsync(
|
||||||
vc1,
|
vc1,
|
||||||
anyOfClass(EntityNonTransactionalQueryContext),
|
anyOfClass(EntityNonTransactionalQueryContext),
|
||||||
|
anything(),
|
||||||
anything()
|
anything()
|
||||||
)
|
)
|
||||||
).once();
|
).once();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
* @module @expo/entity
|
* @module @expo/entity
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
export { default as GenericSecondaryEntityCache } from './GenericSecondaryEntityCache';
|
||||||
export { default as EnforcingEntityLoader } from './EnforcingEntityLoader';
|
export { default as EnforcingEntityLoader } from './EnforcingEntityLoader';
|
||||||
export { default as Entity } from './Entity';
|
export { default as Entity } from './Entity';
|
||||||
export * from './Entity';
|
export * from './Entity';
|
||||||
@@ -33,6 +34,7 @@ export { default as EntitySecondaryCacheLoader } from './EntitySecondaryCacheLoa
|
|||||||
export * from './EntitySecondaryCacheLoader';
|
export * from './EntitySecondaryCacheLoader';
|
||||||
export * from './EntityMutator';
|
export * from './EntityMutator';
|
||||||
export { default as EntityMutationValidator } from './EntityMutationValidator';
|
export { default as EntityMutationValidator } from './EntityMutationValidator';
|
||||||
|
export * from './EntityMutationInfo';
|
||||||
export * from './EntityMutationTriggerConfiguration';
|
export * from './EntityMutationTriggerConfiguration';
|
||||||
export { default as EntityMutationTriggerConfiguration } from './EntityMutationTriggerConfiguration';
|
export { default as EntityMutationTriggerConfiguration } from './EntityMutationTriggerConfiguration';
|
||||||
export { default as EntityMutatorFactory } from './EntityMutatorFactory';
|
export { default as EntityMutatorFactory } from './EntityMutatorFactory';
|
||||||
@@ -42,6 +44,7 @@ export * from './EntityQueryContext';
|
|||||||
export { default as IEntityCacheAdapterProvider } from './IEntityCacheAdapterProvider';
|
export { default as IEntityCacheAdapterProvider } from './IEntityCacheAdapterProvider';
|
||||||
export { default as IEntityDatabaseAdapterProvider } from './IEntityDatabaseAdapterProvider';
|
export { default as IEntityDatabaseAdapterProvider } from './IEntityDatabaseAdapterProvider';
|
||||||
export { default as EntityQueryContextProvider } from './EntityQueryContextProvider';
|
export { default as EntityQueryContextProvider } from './EntityQueryContextProvider';
|
||||||
|
export { default as IEntityGenericCacher } from './IEntityGenericCacher';
|
||||||
export { default as ReadonlyEntity } from './ReadonlyEntity';
|
export { default as ReadonlyEntity } from './ReadonlyEntity';
|
||||||
export { default as ViewerContext } from './ViewerContext';
|
export { default as ViewerContext } from './ViewerContext';
|
||||||
export { default as ViewerScopedEntityCompanion } from './ViewerScopedEntityCompanion';
|
export { default as ViewerScopedEntityCompanion } from './ViewerScopedEntityCompanion';
|
||||||
|
|||||||
@@ -3,11 +3,6 @@ import invariant from 'invariant';
|
|||||||
import EntityCacheAdapter from '../EntityCacheAdapter';
|
import EntityCacheAdapter from '../EntityCacheAdapter';
|
||||||
import EntityConfiguration from '../EntityConfiguration';
|
import EntityConfiguration from '../EntityConfiguration';
|
||||||
import { filterMap } from '../utils/collections/maps';
|
import { filterMap } from '../utils/collections/maps';
|
||||||
import {
|
|
||||||
FieldTransformerMap,
|
|
||||||
transformCacheObjectToFields,
|
|
||||||
transformFieldsToCacheObject,
|
|
||||||
} from './EntityFieldTransformationUtils';
|
|
||||||
|
|
||||||
export enum CacheStatus {
|
export enum CacheStatus {
|
||||||
HIT,
|
HIT,
|
||||||
@@ -15,10 +10,10 @@ export enum CacheStatus {
|
|||||||
NEGATIVE,
|
NEGATIVE,
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CacheLoadResult =
|
export type CacheLoadResult<TFields> =
|
||||||
| {
|
| {
|
||||||
status: CacheStatus.HIT;
|
status: CacheStatus.HIT;
|
||||||
item: Readonly<object>;
|
item: Readonly<TFields>;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
status: CacheStatus.MISS;
|
status: CacheStatus.MISS;
|
||||||
@@ -32,14 +27,10 @@ export type CacheLoadResult =
|
|||||||
* {@link EntityCacheAdapter} within the {@link EntityDataManager}.
|
* {@link EntityCacheAdapter} within the {@link EntityDataManager}.
|
||||||
*/
|
*/
|
||||||
export default class ReadThroughEntityCache<TFields> {
|
export default class ReadThroughEntityCache<TFields> {
|
||||||
private readonly fieldTransformerMap: FieldTransformerMap;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly entityConfiguration: EntityConfiguration<TFields>,
|
private readonly entityConfiguration: EntityConfiguration<TFields>,
|
||||||
private readonly entityCacheAdapter: EntityCacheAdapter<TFields>
|
private readonly entityCacheAdapter: EntityCacheAdapter<TFields>
|
||||||
) {
|
) {}
|
||||||
this.fieldTransformerMap = entityCacheAdapter.getFieldTransformerMap();
|
|
||||||
}
|
|
||||||
|
|
||||||
private isFieldCacheable<N extends keyof TFields>(fieldName: N): boolean {
|
private isFieldCacheable<N extends keyof TFields>(fieldName: N): boolean {
|
||||||
return this.entityConfiguration.cacheableKeys.has(fieldName);
|
return this.entityConfiguration.cacheableKeys.has(fieldName);
|
||||||
@@ -91,13 +82,7 @@ export default class ReadThroughEntityCache<TFields> {
|
|||||||
const results: Map<NonNullable<TFields[N]>, readonly Readonly<TFields>[]> = new Map();
|
const results: Map<NonNullable<TFields[N]>, readonly Readonly<TFields>[]> = new Map();
|
||||||
cacheLoadResults.forEach((cacheLoadResult, fieldValue) => {
|
cacheLoadResults.forEach((cacheLoadResult, fieldValue) => {
|
||||||
if (cacheLoadResult.status === CacheStatus.HIT) {
|
if (cacheLoadResult.status === CacheStatus.HIT) {
|
||||||
results.set(fieldValue, [
|
results.set(fieldValue, [cacheLoadResult.item]);
|
||||||
transformCacheObjectToFields(
|
|
||||||
this.entityConfiguration,
|
|
||||||
this.fieldTransformerMap,
|
|
||||||
cacheLoadResult.item
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -110,7 +95,7 @@ export default class ReadThroughEntityCache<TFields> {
|
|||||||
return !objectsFromFulfillerForFv || objectsFromFulfillerForFv.length === 0;
|
return !objectsFromFulfillerForFv || objectsFromFulfillerForFv.length === 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
const objectsToCache: Map<NonNullable<TFields[N]>, object> = new Map();
|
const objectsToCache: Map<NonNullable<TFields[N]>, Readonly<TFields>> = new Map();
|
||||||
for (const [fieldValue, objects] of dbFetchResults.entries()) {
|
for (const [fieldValue, objects] of dbFetchResults.entries()) {
|
||||||
if (objects.length > 1) {
|
if (objects.length > 1) {
|
||||||
// multiple objects received for what was supposed to be a unique query, don't add to return map nor cache
|
// multiple objects received for what was supposed to be a unique query, don't add to return map nor cache
|
||||||
@@ -123,14 +108,7 @@ export default class ReadThroughEntityCache<TFields> {
|
|||||||
}
|
}
|
||||||
const uniqueObject = objects[0];
|
const uniqueObject = objects[0];
|
||||||
if (uniqueObject) {
|
if (uniqueObject) {
|
||||||
objectsToCache.set(
|
objectsToCache.set(fieldValue, uniqueObject);
|
||||||
fieldValue,
|
|
||||||
transformFieldsToCacheObject(
|
|
||||||
this.entityConfiguration,
|
|
||||||
this.fieldTransformerMap,
|
|
||||||
uniqueObject
|
|
||||||
)
|
|
||||||
);
|
|
||||||
results.set(fieldValue, [uniqueObject]);
|
results.set(fieldValue, [uniqueObject]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ describe(ReadThroughEntityCache, () => {
|
|||||||
describe('readManyThroughAsync', () => {
|
describe('readManyThroughAsync', () => {
|
||||||
it('fetches from DB upon cache miss and caches the result', async () => {
|
it('fetches from DB upon cache miss and caches the result', async () => {
|
||||||
const cacheAdapterMock = mock<EntityCacheAdapter<BlahFields>>();
|
const cacheAdapterMock = mock<EntityCacheAdapter<BlahFields>>();
|
||||||
when(cacheAdapterMock.getFieldTransformerMap()).thenReturn(new Map());
|
|
||||||
const cacheAdapter = instance(cacheAdapterMock);
|
const cacheAdapter = instance(cacheAdapterMock);
|
||||||
const entityCache = new ReadThroughEntityCache(makeEntityConfiguration(true), cacheAdapter);
|
const entityCache = new ReadThroughEntityCache(makeEntityConfiguration(true), cacheAdapter);
|
||||||
const fetcher = createIdFetcher(['wat', 'who']);
|
const fetcher = createIdFetcher(['wat', 'who']);
|
||||||
@@ -77,7 +76,6 @@ describe(ReadThroughEntityCache, () => {
|
|||||||
|
|
||||||
it('does not fetch from the DB or cache results when all cache fetches are hits', async () => {
|
it('does not fetch from the DB or cache results when all cache fetches are hits', async () => {
|
||||||
const cacheAdapterMock = mock<EntityCacheAdapter<BlahFields>>();
|
const cacheAdapterMock = mock<EntityCacheAdapter<BlahFields>>();
|
||||||
when(cacheAdapterMock.getFieldTransformerMap()).thenReturn(new Map());
|
|
||||||
const cacheAdapter = instance(cacheAdapterMock);
|
const cacheAdapter = instance(cacheAdapterMock);
|
||||||
const entityCache = new ReadThroughEntityCache(makeEntityConfiguration(true), cacheAdapter);
|
const entityCache = new ReadThroughEntityCache(makeEntityConfiguration(true), cacheAdapter);
|
||||||
const fetcher = createIdFetcher(['wat', 'who']);
|
const fetcher = createIdFetcher(['wat', 'who']);
|
||||||
@@ -114,7 +112,6 @@ describe(ReadThroughEntityCache, () => {
|
|||||||
|
|
||||||
it('negatively caches db misses', async () => {
|
it('negatively caches db misses', async () => {
|
||||||
const cacheAdapterMock = mock<EntityCacheAdapter<BlahFields>>();
|
const cacheAdapterMock = mock<EntityCacheAdapter<BlahFields>>();
|
||||||
when(cacheAdapterMock.getFieldTransformerMap()).thenReturn(new Map());
|
|
||||||
const cacheAdapter = instance(cacheAdapterMock);
|
const cacheAdapter = instance(cacheAdapterMock);
|
||||||
const entityCache = new ReadThroughEntityCache(makeEntityConfiguration(true), cacheAdapter);
|
const entityCache = new ReadThroughEntityCache(makeEntityConfiguration(true), cacheAdapter);
|
||||||
|
|
||||||
@@ -135,7 +132,6 @@ describe(ReadThroughEntityCache, () => {
|
|||||||
|
|
||||||
it('does not return or fetch negatively cached results from DB', async () => {
|
it('does not return or fetch negatively cached results from DB', async () => {
|
||||||
const cacheAdapterMock = mock<EntityCacheAdapter<BlahFields>>();
|
const cacheAdapterMock = mock<EntityCacheAdapter<BlahFields>>();
|
||||||
when(cacheAdapterMock.getFieldTransformerMap()).thenReturn(new Map());
|
|
||||||
const cacheAdapter = instance(cacheAdapterMock);
|
const cacheAdapter = instance(cacheAdapterMock);
|
||||||
const entityCache = new ReadThroughEntityCache(makeEntityConfiguration(true), cacheAdapter);
|
const entityCache = new ReadThroughEntityCache(makeEntityConfiguration(true), cacheAdapter);
|
||||||
const fetcher = createIdFetcher([]);
|
const fetcher = createIdFetcher([]);
|
||||||
@@ -153,7 +149,6 @@ describe(ReadThroughEntityCache, () => {
|
|||||||
|
|
||||||
it('does a mix and match of hit, miss, and negative', async () => {
|
it('does a mix and match of hit, miss, and negative', async () => {
|
||||||
const cacheAdapterMock = mock<EntityCacheAdapter<BlahFields>>();
|
const cacheAdapterMock = mock<EntityCacheAdapter<BlahFields>>();
|
||||||
when(cacheAdapterMock.getFieldTransformerMap()).thenReturn(new Map());
|
|
||||||
const cacheAdapter = instance(cacheAdapterMock);
|
const cacheAdapter = instance(cacheAdapterMock);
|
||||||
const entityCache = new ReadThroughEntityCache(makeEntityConfiguration(true), cacheAdapter);
|
const entityCache = new ReadThroughEntityCache(makeEntityConfiguration(true), cacheAdapter);
|
||||||
const fetcher = createIdFetcher(['wat', 'who', 'why']);
|
const fetcher = createIdFetcher(['wat', 'who', 'why']);
|
||||||
@@ -189,7 +184,6 @@ describe(ReadThroughEntityCache, () => {
|
|||||||
|
|
||||||
it('does not call into cache for field that is not cacheable', async () => {
|
it('does not call into cache for field that is not cacheable', async () => {
|
||||||
const cacheAdapterMock = mock<EntityCacheAdapter<BlahFields>>();
|
const cacheAdapterMock = mock<EntityCacheAdapter<BlahFields>>();
|
||||||
when(cacheAdapterMock.getFieldTransformerMap()).thenReturn(new Map());
|
|
||||||
const cacheAdapter = instance(cacheAdapterMock);
|
const cacheAdapter = instance(cacheAdapterMock);
|
||||||
const entityCache = new ReadThroughEntityCache(makeEntityConfiguration(false), cacheAdapter);
|
const entityCache = new ReadThroughEntityCache(makeEntityConfiguration(false), cacheAdapter);
|
||||||
const fetcher = createIdFetcher(['wat']);
|
const fetcher = createIdFetcher(['wat']);
|
||||||
@@ -197,44 +191,6 @@ describe(ReadThroughEntityCache, () => {
|
|||||||
verify(cacheAdapterMock.loadManyAsync('id', anything())).never();
|
verify(cacheAdapterMock.loadManyAsync('id', anything())).never();
|
||||||
expect(result).toEqual(new Map([['wat', [{ id: 'wat' }]]]));
|
expect(result).toEqual(new Map([['wat', [{ id: 'wat' }]]]));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('transforms fields for cache storage', async () => {
|
|
||||||
const cacheAdapterMock = mock<EntityCacheAdapter<BlahFields>>();
|
|
||||||
when(cacheAdapterMock.getFieldTransformerMap()).thenReturn(
|
|
||||||
new Map([
|
|
||||||
[
|
|
||||||
UUIDField.name,
|
|
||||||
{
|
|
||||||
read: (val) => val.split('-')[0],
|
|
||||||
write: (val) => `${val}-in-cache`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
])
|
|
||||||
);
|
|
||||||
const cacheAdapter = instance(cacheAdapterMock);
|
|
||||||
const entityCache = new ReadThroughEntityCache(makeEntityConfiguration(true), cacheAdapter);
|
|
||||||
const fetcher = createIdFetcher(['wat', 'who']);
|
|
||||||
|
|
||||||
when(cacheAdapterMock.loadManyAsync('id', deepEqual(['wat', 'who']))).thenResolve(
|
|
||||||
new Map([
|
|
||||||
['wat', { status: CacheStatus.MISS }],
|
|
||||||
['who', { status: CacheStatus.HIT, item: { id: 'who-in-cache' } }],
|
|
||||||
])
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await entityCache.readManyThroughAsync('id', ['wat', 'who'], fetcher);
|
|
||||||
|
|
||||||
verify(cacheAdapterMock.loadManyAsync('id', deepEqual(['wat', 'who']))).once();
|
|
||||||
verify(
|
|
||||||
cacheAdapterMock.cacheManyAsync('id', deepEqual(new Map([['wat', { id: 'wat-in-cache' }]])))
|
|
||||||
).once();
|
|
||||||
expect(result).toEqual(
|
|
||||||
new Map([
|
|
||||||
['wat', [{ id: 'wat' }]],
|
|
||||||
['who', [{ id: 'who' }]],
|
|
||||||
])
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('invalidateManyAsync', () => {
|
describe('invalidateManyAsync', () => {
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
import {
|
||||||
|
EntityAuthorizationAction,
|
||||||
|
EntityPrivacyPolicyEvaluationMode,
|
||||||
|
} from '../EntityPrivacyPolicy';
|
||||||
|
|
||||||
export enum EntityMetricsLoadType {
|
export enum EntityMetricsLoadType {
|
||||||
LOAD_MANY,
|
LOAD_MANY,
|
||||||
LOAD_MANY_EQUALITY_CONJUNCTION,
|
LOAD_MANY_EQUALITY_CONJUNCTION,
|
||||||
@@ -28,11 +33,29 @@ export interface IncrementLoadCountEvent {
|
|||||||
entityClassName: string;
|
entityClassName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum EntityMetricsAuthorizationResult {
|
||||||
|
DENY,
|
||||||
|
ALLOW,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntityMetricsAuthorizationEvent {
|
||||||
|
entityClassName: string;
|
||||||
|
action: EntityAuthorizationAction;
|
||||||
|
evaluationResult: EntityMetricsAuthorizationResult;
|
||||||
|
privacyPolicyEvaluationMode: EntityPrivacyPolicyEvaluationMode;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An interface for gathering metrics about the Entity framework. Information about
|
* An interface for gathering metrics about the Entity framework. Information about
|
||||||
* entity load and mutation operations is piped to an instance of this adapter.
|
* entity load and mutation operations is piped to an instance of this adapter.
|
||||||
*/
|
*/
|
||||||
export default interface IEntityMetricsAdapter {
|
export default interface IEntityMetricsAdapter {
|
||||||
|
/**
|
||||||
|
* Called when a {@link EntityPrivacyPolicy} authorization succeeds or fails.
|
||||||
|
* @param authorizationEvent - info about the authorization event
|
||||||
|
*/
|
||||||
|
logAuthorizationEvent(authorizationEvent: EntityMetricsAuthorizationEvent): void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called when any load occurs.
|
* Called when any load occurs.
|
||||||
* @param loadEvent - info about the load event
|
* @param loadEvent - info about the load event
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import IEntityMetricsAdapter, {
|
import IEntityMetricsAdapter, {
|
||||||
|
EntityMetricsAuthorizationEvent,
|
||||||
EntityMetricsLoadEvent,
|
EntityMetricsLoadEvent,
|
||||||
EntityMetricsMutationEvent,
|
EntityMetricsMutationEvent,
|
||||||
IncrementLoadCountEvent,
|
IncrementLoadCountEvent,
|
||||||
} from './IEntityMetricsAdapter';
|
} from './IEntityMetricsAdapter';
|
||||||
|
|
||||||
export default class NoOpEntityMetricsAdapter implements IEntityMetricsAdapter {
|
export default class NoOpEntityMetricsAdapter implements IEntityMetricsAdapter {
|
||||||
|
logAuthorizationEvent(_authorizationEvent: EntityMetricsAuthorizationEvent): void {}
|
||||||
logDataManagerLoadEvent(_loadEvent: EntityMetricsLoadEvent): void {}
|
logDataManagerLoadEvent(_loadEvent: EntityMetricsLoadEvent): void {}
|
||||||
logMutatorMutationEvent(_mutationEvent: EntityMetricsMutationEvent): void {}
|
logMutatorMutationEvent(_mutationEvent: EntityMetricsMutationEvent): void {}
|
||||||
incrementDataManagerDataloaderLoadCount(
|
incrementDataManagerDataloaderLoadCount(
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import invariant from 'invariant';
|
|||||||
import EntityCacheAdapter from '../../EntityCacheAdapter';
|
import EntityCacheAdapter from '../../EntityCacheAdapter';
|
||||||
import EntityConfiguration from '../../EntityConfiguration';
|
import EntityConfiguration from '../../EntityConfiguration';
|
||||||
import IEntityCacheAdapterProvider from '../../IEntityCacheAdapterProvider';
|
import IEntityCacheAdapterProvider from '../../IEntityCacheAdapterProvider';
|
||||||
import { FieldTransformerMap } from '../../internal/EntityFieldTransformationUtils';
|
|
||||||
import { CacheStatus, CacheLoadResult } from '../../internal/ReadThroughEntityCache';
|
import { CacheStatus, CacheLoadResult } from '../../internal/ReadThroughEntityCache';
|
||||||
|
|
||||||
export class NoCacheStubCacheAdapterProvider implements IEntityCacheAdapterProvider {
|
export class NoCacheStubCacheAdapterProvider implements IEntityCacheAdapterProvider {
|
||||||
@@ -15,15 +14,11 @@ export class NoCacheStubCacheAdapterProvider implements IEntityCacheAdapterProvi
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class NoCacheStubCacheAdapter<TFields> extends EntityCacheAdapter<TFields> {
|
export class NoCacheStubCacheAdapter<TFields> extends EntityCacheAdapter<TFields> {
|
||||||
public getFieldTransformerMap(): FieldTransformerMap {
|
|
||||||
return new Map();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async loadManyAsync<N extends keyof TFields>(
|
public async loadManyAsync<N extends keyof TFields>(
|
||||||
_fieldName: N,
|
_fieldName: N,
|
||||||
fieldValues: readonly NonNullable<TFields[N]>[]
|
fieldValues: readonly NonNullable<TFields[N]>[]
|
||||||
): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult>> {
|
): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult<TFields>>> {
|
||||||
return fieldValues.reduce((acc: Map<NonNullable<TFields[N]>, CacheLoadResult>, v) => {
|
return fieldValues.reduce((acc: Map<NonNullable<TFields[N]>, CacheLoadResult<TFields>>, v) => {
|
||||||
acc.set(v, {
|
acc.set(v, {
|
||||||
status: CacheStatus.MISS,
|
status: CacheStatus.MISS,
|
||||||
});
|
});
|
||||||
@@ -33,7 +28,7 @@ export class NoCacheStubCacheAdapter<TFields> extends EntityCacheAdapter<TFields
|
|||||||
|
|
||||||
public async cacheManyAsync<N extends keyof TFields>(
|
public async cacheManyAsync<N extends keyof TFields>(
|
||||||
_fieldName: N,
|
_fieldName: N,
|
||||||
_objectMap: ReadonlyMap<NonNullable<TFields[N]>, object>
|
_objectMap: ReadonlyMap<NonNullable<TFields[N]>, Readonly<TFields>>
|
||||||
): Promise<void> {}
|
): Promise<void> {}
|
||||||
|
|
||||||
public async cacheDBMissesAsync<N extends keyof TFields>(
|
public async cacheDBMissesAsync<N extends keyof TFields>(
|
||||||
@@ -53,27 +48,26 @@ export class InMemoryFullCacheStubCacheAdapterProvider implements IEntityCacheAd
|
|||||||
getCacheAdapter<TFields>(
|
getCacheAdapter<TFields>(
|
||||||
entityConfiguration: EntityConfiguration<TFields>
|
entityConfiguration: EntityConfiguration<TFields>
|
||||||
): EntityCacheAdapter<TFields> {
|
): EntityCacheAdapter<TFields> {
|
||||||
return new InMemoryFullCacheStubCacheAdapter(entityConfiguration, this.cache);
|
return new InMemoryFullCacheStubCacheAdapter(
|
||||||
|
entityConfiguration,
|
||||||
|
this.cache as Map<string, Readonly<TFields>>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class InMemoryFullCacheStubCacheAdapter<TFields> extends EntityCacheAdapter<TFields> {
|
export class InMemoryFullCacheStubCacheAdapter<TFields> extends EntityCacheAdapter<TFields> {
|
||||||
constructor(
|
constructor(
|
||||||
entityConfiguration: EntityConfiguration<TFields>,
|
entityConfiguration: EntityConfiguration<TFields>,
|
||||||
readonly cache: Map<string, Readonly<object>>
|
readonly cache: Map<string, Readonly<TFields>>
|
||||||
) {
|
) {
|
||||||
super(entityConfiguration);
|
super(entityConfiguration);
|
||||||
}
|
}
|
||||||
|
|
||||||
public getFieldTransformerMap(): FieldTransformerMap {
|
|
||||||
return new Map();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async loadManyAsync<N extends keyof TFields>(
|
public async loadManyAsync<N extends keyof TFields>(
|
||||||
fieldName: N,
|
fieldName: N,
|
||||||
fieldValues: readonly NonNullable<TFields[N]>[]
|
fieldValues: readonly NonNullable<TFields[N]>[]
|
||||||
): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult>> {
|
): Promise<ReadonlyMap<NonNullable<TFields[N]>, CacheLoadResult<TFields>>> {
|
||||||
const results = new Map<NonNullable<TFields[N]>, CacheLoadResult>();
|
const results = new Map<NonNullable<TFields[N]>, CacheLoadResult<TFields>>();
|
||||||
fieldValues.forEach((fieldValue) => {
|
fieldValues.forEach((fieldValue) => {
|
||||||
const cacheKey = this.createCacheKey(fieldName, fieldValue);
|
const cacheKey = this.createCacheKey(fieldName, fieldValue);
|
||||||
if (!this.cache.has(cacheKey)) {
|
if (!this.cache.has(cacheKey)) {
|
||||||
@@ -94,7 +88,7 @@ export class InMemoryFullCacheStubCacheAdapter<TFields> extends EntityCacheAdapt
|
|||||||
|
|
||||||
public async cacheManyAsync<N extends keyof TFields>(
|
public async cacheManyAsync<N extends keyof TFields>(
|
||||||
fieldName: N,
|
fieldName: N,
|
||||||
objectMap: ReadonlyMap<NonNullable<TFields[N]>, object>
|
objectMap: ReadonlyMap<NonNullable<TFields[N]>, Readonly<TFields>>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
objectMap.forEach((obj, fieldValue) => {
|
objectMap.forEach((obj, fieldValue) => {
|
||||||
const cacheKey = this.createCacheKey(fieldName, fieldValue);
|
const cacheKey = this.createCacheKey(fieldName, fieldValue);
|
||||||
|
|||||||
@@ -507,23 +507,33 @@
|
|||||||
exec-sh "^0.3.2"
|
exec-sh "^0.3.2"
|
||||||
minimist "^1.2.0"
|
minimist "^1.2.0"
|
||||||
|
|
||||||
|
"@expo/entity-cache-adapter-local-memory@file:packages/entity-cache-adapter-local-memory":
|
||||||
|
version "0.23.0"
|
||||||
|
dependencies:
|
||||||
|
lru-cache "^7.3.1"
|
||||||
|
|
||||||
"@expo/entity-cache-adapter-redis@file:packages/entity-cache-adapter-redis":
|
"@expo/entity-cache-adapter-redis@file:packages/entity-cache-adapter-redis":
|
||||||
version "0.17.0"
|
version "0.23.0"
|
||||||
dependencies:
|
dependencies:
|
||||||
ioredis "^4.27.3"
|
ioredis "^4.27.3"
|
||||||
|
|
||||||
"@expo/entity-database-adapter-knex@file:packages/entity-database-adapter-knex":
|
"@expo/entity-database-adapter-knex@file:packages/entity-database-adapter-knex":
|
||||||
version "0.17.0"
|
version "0.23.0"
|
||||||
dependencies:
|
dependencies:
|
||||||
knex "^0.95.6"
|
knex "^1.0.2"
|
||||||
|
|
||||||
|
"@expo/entity-ip-address-field@file:packages/entity-ip-address-field":
|
||||||
|
version "0.23.0"
|
||||||
|
dependencies:
|
||||||
|
ip-address "^8.1.0"
|
||||||
|
|
||||||
"@expo/entity-secondary-cache-redis@file:packages/entity-secondary-cache-redis":
|
"@expo/entity-secondary-cache-redis@file:packages/entity-secondary-cache-redis":
|
||||||
version "0.17.0"
|
version "0.23.0"
|
||||||
dependencies:
|
dependencies:
|
||||||
ioredis "^4.17.3"
|
ioredis "^4.17.3"
|
||||||
|
|
||||||
"@expo/entity@file:packages/entity":
|
"@expo/entity@file:packages/entity":
|
||||||
version "0.17.0"
|
version "0.23.0"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@expo/results" "^1.0.0"
|
"@expo/results" "^1.0.0"
|
||||||
dataloader "^2.0.0"
|
dataloader "^2.0.0"
|
||||||
@@ -1910,6 +1920,11 @@
|
|||||||
jest-diff "^25.2.1"
|
jest-diff "^25.2.1"
|
||||||
pretty-format "^25.2.1"
|
pretty-format "^25.2.1"
|
||||||
|
|
||||||
|
"@types/jsbn@^1.2.29":
|
||||||
|
version "1.2.29"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/jsbn/-/jsbn-1.2.29.tgz#28229bc0262c704a1506c3ed69a7d7e115bd7832"
|
||||||
|
integrity sha512-2dVz9LTEGWVj9Ov9zaDnpvqHFV+W4bXtU0EUEGAzWfdRNO3dlUuosdHpENI6/oQW+Kejn0hAjk6P/czs9h/hvg==
|
||||||
|
|
||||||
"@types/json-schema@^7.0.3":
|
"@types/json-schema@^7.0.3":
|
||||||
version "7.0.4"
|
version "7.0.4"
|
||||||
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.4.tgz#38fd73ddfd9b55abb1e1b2ed578cb55bd7b7d339"
|
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.4.tgz#38fd73ddfd9b55abb1e1b2ed578cb55bd7b7d339"
|
||||||
@@ -1985,6 +2000,11 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@types/long/-/long-4.0.1.tgz#459c65fa1867dafe6a8f322c4c51695663cc55e9"
|
resolved "https://registry.yarnpkg.com/@types/long/-/long-4.0.1.tgz#459c65fa1867dafe6a8f322c4c51695663cc55e9"
|
||||||
integrity sha512-5tXH6Bx/kNGd3MgffdmP4dy2Z+G4eaXw0SE81Tq3BNadtnMR5/ySMzX4SLEzHJzSmPNn4HIdpQsBvXMUykr58w==
|
integrity sha512-5tXH6Bx/kNGd3MgffdmP4dy2Z+G4eaXw0SE81Tq3BNadtnMR5/ySMzX4SLEzHJzSmPNn4HIdpQsBvXMUykr58w==
|
||||||
|
|
||||||
|
"@types/lru-cache@^5.1.1":
|
||||||
|
version "5.1.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/lru-cache/-/lru-cache-5.1.1.tgz#c48c2e27b65d2a153b19bfc1a317e30872e01eef"
|
||||||
|
integrity sha512-ssE3Vlrys7sdIzs5LOxCzTVMsU7i9oa/IaW92wF32JFb3CVczqOkru2xspuKczHEbG3nvmPY7IFqVmGGHdNbYw==
|
||||||
|
|
||||||
"@types/mime@*":
|
"@types/mime@*":
|
||||||
version "2.0.1"
|
version "2.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/@types/mime/-/mime-2.0.1.tgz#dc488842312a7f075149312905b5e3c0b054c79d"
|
resolved "https://registry.yarnpkg.com/@types/mime/-/mime-2.0.1.tgz#dc488842312a7f075149312905b5e3c0b054c79d"
|
||||||
@@ -3260,10 +3280,10 @@ color-name@~1.1.4:
|
|||||||
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
|
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
|
||||||
integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
|
integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
|
||||||
|
|
||||||
colorette@1.2.1:
|
colorette@2.0.16:
|
||||||
version "1.2.1"
|
version "2.0.16"
|
||||||
resolved "https://registry.yarnpkg.com/colorette/-/colorette-1.2.1.tgz#4d0b921325c14faf92633086a536db6e89564b1b"
|
resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.16.tgz#713b9af84fdb000139f04546bd4a93f62a5085da"
|
||||||
integrity sha512-puCDz0CzydiSYOrnXpz/PKd69zRrribezjtE9yd4zvytoRc8+RY/KJPvtPFKZS3E3wP6neGyMe0vOTlHO5L3Pw==
|
integrity sha512-hUewv7oMjCp+wkBv5Rm0v87eJhq4woh5rSR+42YSQJKecCqgIqNkZ6lAlQms/BwHPJA5NKMRlpxPRv0n8HQW6g==
|
||||||
|
|
||||||
columnify@^1.5.4:
|
columnify@^1.5.4:
|
||||||
version "1.5.4"
|
version "1.5.4"
|
||||||
@@ -3285,10 +3305,10 @@ commander@^2.20.3, commander@~2.20.3:
|
|||||||
resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
|
resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
|
||||||
integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
|
integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
|
||||||
|
|
||||||
commander@^7.1.0:
|
commander@^8.3.0:
|
||||||
version "7.2.0"
|
version "8.3.0"
|
||||||
resolved "https://registry.yarnpkg.com/commander/-/commander-7.2.0.tgz#a36cb57d0b501ce108e4d20559a150a391d97ab7"
|
resolved "https://registry.yarnpkg.com/commander/-/commander-8.3.0.tgz#4837ea1b2da67b9c616a67afbb0fafee567bca66"
|
||||||
integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==
|
integrity sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==
|
||||||
|
|
||||||
compare-func@^2.0.0:
|
compare-func@^2.0.0:
|
||||||
version "2.0.0"
|
version "2.0.0"
|
||||||
@@ -3572,13 +3592,20 @@ dateformat@~1.0.4-1.2.3:
|
|||||||
get-stdin "^4.0.1"
|
get-stdin "^4.0.1"
|
||||||
meow "^3.3.0"
|
meow "^3.3.0"
|
||||||
|
|
||||||
debug@4, [email protected], debug@^4.3.1:
|
debug@4, debug@^4.3.1:
|
||||||
version "4.3.1"
|
version "4.3.1"
|
||||||
resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.1.tgz#f0d229c505e0c6d8c49ac553d1b13dc183f6b2ee"
|
resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.1.tgz#f0d229c505e0c6d8c49ac553d1b13dc183f6b2ee"
|
||||||
integrity sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==
|
integrity sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
ms "2.1.2"
|
ms "2.1.2"
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
version "4.3.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.3.tgz#04266e0b70a98d4462e6e288e38259213332b664"
|
||||||
|
integrity sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q==
|
||||||
|
dependencies:
|
||||||
|
ms "2.1.2"
|
||||||
|
|
||||||
debug@^2.2.0, debug@^2.3.3, debug@^2.6.9:
|
debug@^2.2.0, debug@^2.3.3, debug@^2.6.9:
|
||||||
version "2.6.9"
|
version "2.6.9"
|
||||||
resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
|
resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
|
||||||
@@ -4684,10 +4711,10 @@ get-value@^2.0.3, get-value@^2.0.6:
|
|||||||
resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28"
|
resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28"
|
||||||
integrity sha1-3BXKHGcjh8p2vTesCjlbogQqLCg=
|
integrity sha1-3BXKHGcjh8p2vTesCjlbogQqLCg=
|
||||||
|
|
||||||
getopts@2.2.5:
|
getopts@2.3.0:
|
||||||
version "2.2.5"
|
version "2.3.0"
|
||||||
resolved "https://registry.yarnpkg.com/getopts/-/getopts-2.2.5.tgz#67a0fe471cacb9c687d817cab6450b96dde8313b"
|
resolved "https://registry.yarnpkg.com/getopts/-/getopts-2.3.0.tgz#71e5593284807e03e2427449d4f6712a268666f4"
|
||||||
integrity sha512-9jb7AW5p3in+IiJWhQiZmmwkpLaR/ccTWdWQCtZM66HJcHHLegowh4q4tSD7gouUyeNvFWRavfK9GXosQHDpFA==
|
integrity sha512-5eDf9fuSXwxBL6q5HX+dhDj+dslFGWzU5thZ9kNKUkcPtaPdatmUFKwHFrLb/uf/WpA4BHET+AX3Scl56cAjpA==
|
||||||
|
|
||||||
getpass@^0.1.1:
|
getpass@^0.1.1:
|
||||||
version "0.1.7"
|
version "0.1.7"
|
||||||
@@ -5257,6 +5284,14 @@ ioredis@^4.27.3:
|
|||||||
redis-parser "^3.0.0"
|
redis-parser "^3.0.0"
|
||||||
standard-as-callback "^2.1.0"
|
standard-as-callback "^2.1.0"
|
||||||
|
|
||||||
|
ip-address@^8.1.0:
|
||||||
|
version "8.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-8.1.0.tgz#1fe9b4509b51ff7d2fbbef4d3d26994d9915a459"
|
||||||
|
integrity sha512-Wz91gZKpNKoXtqvY8ScarKYwhXoK4r/b5QuT+uywe/azv0/nUCo7Bh0IRRI7F9DHR06kJNWtzMGLIbXavngbKA==
|
||||||
|
dependencies:
|
||||||
|
jsbn "1.1.0"
|
||||||
|
sprintf-js "1.1.2"
|
||||||
|
|
||||||
ip-regex@^2.1.0:
|
ip-regex@^2.1.0:
|
||||||
version "2.1.0"
|
version "2.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-2.1.0.tgz#fa78bf5d2e6913c911ce9f819ee5146bb6d844e9"
|
resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-2.1.0.tgz#fa78bf5d2e6913c911ce9f819ee5146bb6d844e9"
|
||||||
@@ -6044,6 +6079,11 @@ js-yaml@^3.13.1:
|
|||||||
argparse "^1.0.7"
|
argparse "^1.0.7"
|
||||||
esprima "^4.0.0"
|
esprima "^4.0.0"
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
version "1.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-1.1.0.tgz#b01307cb29b618a1ed26ec79e911f803c4da0040"
|
||||||
|
integrity sha1-sBMHyym2GKHtJux56RH4A8TaAEA=
|
||||||
|
|
||||||
jsbn@~0.1.0:
|
jsbn@~0.1.0:
|
||||||
version "0.1.1"
|
version "0.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513"
|
resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513"
|
||||||
@@ -6205,23 +6245,23 @@ kleur@^3.0.3:
|
|||||||
resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e"
|
resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e"
|
||||||
integrity sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==
|
integrity sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==
|
||||||
|
|
||||||
knex@^0.95.6:
|
knex@^1.0.2:
|
||||||
version "0.95.6"
|
version "1.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/knex/-/knex-0.95.6.tgz#5fc60ffc2935567bf122925526b1b06b8dbca785"
|
resolved "https://registry.yarnpkg.com/knex/-/knex-1.0.2.tgz#1b79273f39f587a631c1a5515482c203d5971781"
|
||||||
integrity sha512-noRcmkJl1MdicUbezrcr8OtVLcqQ/cfLIwgAx5EaxNxQOIJff88rBeyLywUScGhQNd/b78DIKKXZzLMrm6h/cw==
|
integrity sha512-RuDKTylj6X/3nYomnsFV8sOdxTcehLHczOd3yrUdULE4pQR8jVlZxYt3vvIU04otJF0Cw9DCtRt05S4PN4kDpw==
|
||||||
dependencies:
|
dependencies:
|
||||||
colorette "1.2.1"
|
colorette "2.0.16"
|
||||||
commander "^7.1.0"
|
commander "^8.3.0"
|
||||||
debug "4.3.1"
|
debug "4.3.3"
|
||||||
escalade "^3.1.1"
|
escalade "^3.1.1"
|
||||||
esm "^3.2.25"
|
esm "^3.2.25"
|
||||||
getopts "2.2.5"
|
getopts "2.3.0"
|
||||||
interpret "^2.2.0"
|
interpret "^2.2.0"
|
||||||
lodash "^4.17.21"
|
lodash "^4.17.21"
|
||||||
pg-connection-string "2.4.0"
|
pg-connection-string "2.5.0"
|
||||||
rechoir "^0.7.0"
|
rechoir "^0.8.0"
|
||||||
resolve-from "^5.0.0"
|
resolve-from "^5.0.0"
|
||||||
tarn "^3.0.1"
|
tarn "^3.0.2"
|
||||||
tildify "2.0.0"
|
tildify "2.0.0"
|
||||||
|
|
||||||
koa-body@^4.2.0:
|
koa-body@^4.2.0:
|
||||||
@@ -6535,6 +6575,11 @@ lru-cache@^6.0.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
yallist "^4.0.0"
|
yallist "^4.0.0"
|
||||||
|
|
||||||
|
lru-cache@^7.3.1:
|
||||||
|
version "7.3.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.3.1.tgz#7702e80694ec2bf19865567a469f2b081fcf53f5"
|
||||||
|
integrity sha512-nX1x4qUrKqwbIAhv4s9et4FIUVzNOpeY07bsjGUy8gwJrXH/wScImSQqXErmo/b2jZY2r0mohbLA9zVj7u1cNw==
|
||||||
|
|
||||||
lunr@^2.3.9:
|
lunr@^2.3.9:
|
||||||
version "2.3.9"
|
version "2.3.9"
|
||||||
resolved "https://registry.yarnpkg.com/lunr/-/lunr-2.3.9.tgz#18b123142832337dd6e964df1a5a7707b25d35e1"
|
resolved "https://registry.yarnpkg.com/lunr/-/lunr-2.3.9.tgz#18b123142832337dd6e964df1a5a7707b25d35e1"
|
||||||
@@ -7646,12 +7691,7 @@ performance-now@^2.1.0:
|
|||||||
resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b"
|
resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b"
|
||||||
integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=
|
integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=
|
||||||
|
|
||||||
pg-connection-string@2.4.0:
|
pg-connection-string@2.5.0, pg-connection-string@^2.5.0:
|
||||||
version "2.4.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.4.0.tgz#c979922eb47832999a204da5dbe1ebf2341b6a10"
|
|
||||||
integrity sha512-3iBXuv7XKvxeMrIgym7njT+HlZkwZqqGX4Bu9cci8xHZNT+Um1gWKqCsAzcC0d95rcKMU5WBg6YRUcHyV0HZKQ==
|
|
||||||
|
|
||||||
pg-connection-string@^2.5.0:
|
|
||||||
version "2.5.0"
|
version "2.5.0"
|
||||||
resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.5.0.tgz#538cadd0f7e603fc09a12590f3b8a452c2c0cf34"
|
resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.5.0.tgz#538cadd0f7e603fc09a12590f3b8a452c2c0cf34"
|
||||||
integrity sha512-r5o/V/ORTA6TmUnyWZR9nCj1klXCO2CEKNRlVuJptZe85QuhFayC7WeMic7ndayT5IRIR0S0xFxFi2ousartlQ==
|
integrity sha512-r5o/V/ORTA6TmUnyWZR9nCj1klXCO2CEKNRlVuJptZe85QuhFayC7WeMic7ndayT5IRIR0S0xFxFi2ousartlQ==
|
||||||
@@ -8116,12 +8156,12 @@ rechoir@^0.6.2:
|
|||||||
dependencies:
|
dependencies:
|
||||||
resolve "^1.1.6"
|
resolve "^1.1.6"
|
||||||
|
|
||||||
rechoir@^0.7.0:
|
rechoir@^0.8.0:
|
||||||
version "0.7.0"
|
version "0.8.0"
|
||||||
resolved "https://registry.yarnpkg.com/rechoir/-/rechoir-0.7.0.tgz#32650fd52c21ab252aa5d65b19310441c7e03aca"
|
resolved "https://registry.yarnpkg.com/rechoir/-/rechoir-0.8.0.tgz#49f866e0d32146142da3ad8f0eff352b3215ff22"
|
||||||
integrity sha512-ADsDEH2bvbjltXEP+hTIAmeFekTFK0V2BTxMkok6qILyAJEXV0AFfoWcAq4yfll5VdIMd/RVXq0lR+wQi5ZU3Q==
|
integrity sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
resolve "^1.9.0"
|
resolve "^1.20.0"
|
||||||
|
|
||||||
redent@^1.0.0:
|
redent@^1.0.0:
|
||||||
version "1.0.0"
|
version "1.0.0"
|
||||||
@@ -8293,7 +8333,7 @@ resolve@^1.18.1, resolve@~1.19.0:
|
|||||||
is-core-module "^2.1.0"
|
is-core-module "^2.1.0"
|
||||||
path-parse "^1.0.6"
|
path-parse "^1.0.6"
|
||||||
|
|
||||||
resolve@^1.20.0, resolve@^1.9.0:
|
resolve@^1.20.0:
|
||||||
version "1.20.0"
|
version "1.20.0"
|
||||||
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.20.0.tgz#629a013fb3f70755d6f0b7935cc1c2c5378b1975"
|
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.20.0.tgz#629a013fb3f70755d6f0b7935cc1c2c5378b1975"
|
||||||
integrity sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A==
|
integrity sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A==
|
||||||
@@ -8704,6 +8744,11 @@ split@^1.0.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
through "2"
|
through "2"
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
version "1.1.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.1.2.tgz#da1765262bf8c0f571749f2ad6c26300207ae673"
|
||||||
|
integrity sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug==
|
||||||
|
|
||||||
sprintf-js@~1.0.2:
|
sprintf-js@~1.0.2:
|
||||||
version "1.0.3"
|
version "1.0.3"
|
||||||
resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c"
|
resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c"
|
||||||
@@ -9080,10 +9125,10 @@ tar@^6.0.2, tar@^6.1.0:
|
|||||||
mkdirp "^1.0.3"
|
mkdirp "^1.0.3"
|
||||||
yallist "^4.0.0"
|
yallist "^4.0.0"
|
||||||
|
|
||||||
tarn@^3.0.1:
|
tarn@^3.0.2:
|
||||||
version "3.0.1"
|
version "3.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/tarn/-/tarn-3.0.1.tgz#ebac2c6dbc6977d34d4526e0a7814200386a8aec"
|
resolved "https://registry.yarnpkg.com/tarn/-/tarn-3.0.2.tgz#73b6140fbb881b71559c4f8bfde3d9a4b3d27693"
|
||||||
integrity sha512-6usSlV9KyHsspvwu2duKH+FMUhqJnAh6J5J/4MITl8s94iSUQTLkJggdiewKv4RyARQccnigV48Z+khiuVZDJw==
|
integrity sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ==
|
||||||
|
|
||||||
temp-dir@^1.0.0:
|
temp-dir@^1.0.0:
|
||||||
version "1.0.0"
|
version "1.0.0"
|
||||||
|
|||||||
Reference in new issue
Block a user