Skip to content

Commit 66c0122

Browse files
feat: pass deletePrefix and delete spread through dedupe (#319)
1 parent b8ffa7a commit 66c0122

File tree

9 files changed

+418
-90
lines changed

9 files changed

+418
-90
lines changed

docs/dataloader.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,45 @@ function onUserChanged(id: DbUser['id']) {
349349
}
350350
```
351351

352+
One downside of this approach is that you can only use this outside of a transaction. The simplest way to resolve this is to separate the implementation from the caching:
353+
354+
```typescript
355+
import {dedupeAsync} from '@databases/dataloader';
356+
import database, {tables, DbUser} from './database';
357+
358+
async function getUserBase(
359+
database: Queryable,
360+
userId: DbUser['id'],
361+
): Promise<DbUser> {
362+
return await tables.users(database).findOneRequired({id: userId});
363+
}
364+
365+
// (userId: DbUser['id']) => Promise<DbUser>
366+
const getUserCached = dedupeAsync<DbUser['id'], DbUser>(
367+
async (userId) => await getUserBase(userId, database),
368+
{cache: createCache({name: 'Users'})},
369+
);
370+
371+
export async function getUser(
372+
db: Queryable,
373+
userId: DbUser['id'],
374+
): Promise<DbUser> {
375+
if (db === database) {
376+
// If we're using the default connection,
377+
// it's safe to read from the cache
378+
return await getUserCached(userId);
379+
} else {
380+
// If we're inside a transaction, we may
381+
// need to bypass the cache
382+
return await getUserBase(db, userId);
383+
}
384+
}
385+
386+
function onUserChanged(id: DbUser['id']) {
387+
getUserCached.cache.delete(id);
388+
}
389+
```
390+
352391
#### Caching fetch requests
353392

354393
The following example caches requests to load a user from some imaginary API.

packages/cache/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -442,7 +442,7 @@ export default function createCacheRealm(
442442
}
443443
this._delete(k);
444444
}
445-
if (onReplicationEvent) {
445+
if (onReplicationEvent && serializedKeys.size) {
446446
onReplicationEvent({
447447
kind: 'DELETE_MULTIPLE',
448448
name: this.name,
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import {AsyncCacheMap, CacheMap, CacheMapInput, KeyPrefix} from './types';
2+
3+
const supportsDeleteSpreadCache = new WeakMap<Function, boolean>();
4+
5+
function supportsDeleteSpreadUncached<TKey, TValue>(
6+
cacheMap: CacheMapInput<TKey, TValue>,
7+
): boolean {
8+
return /^[^={]*\.\.\./.test(cacheMap.delete.toString());
9+
}
10+
11+
function supportsDeleteSpread<TKey, TValue>(
12+
cacheMap: CacheMapInput<TKey, TValue>,
13+
): boolean {
14+
if (cacheMap.constructor === Map || cacheMap.constructor === WeakMap) {
15+
return false;
16+
}
17+
if (cacheMap.constructor === Object || cacheMap.constructor === Function) {
18+
return supportsDeleteSpreadUncached(cacheMap);
19+
}
20+
21+
const cached = supportsDeleteSpreadCache.get(cacheMap.constructor);
22+
if (cached !== undefined) return cached;
23+
24+
const freshValue = supportsDeleteSpreadUncached(cacheMap);
25+
supportsDeleteSpreadCache.set(cacheMap.constructor, freshValue);
26+
return freshValue;
27+
}
28+
29+
class CacheMapImplementation<TKey, TResult, TMappedKey>
30+
implements CacheMap<TKey, TResult>
31+
{
32+
private readonly _map: CacheMapInput<TMappedKey, TResult>;
33+
private readonly _mapKey: (key: TKey) => TMappedKey;
34+
private readonly _supportsDeleteSpread: boolean;
35+
constructor(
36+
map: CacheMapInput<TMappedKey, TResult>,
37+
mapKey: (key: TKey) => TMappedKey,
38+
) {
39+
this._map = map;
40+
this._mapKey = mapKey;
41+
this._supportsDeleteSpread = supportsDeleteSpread(map);
42+
}
43+
44+
get size() {
45+
return this._map.size;
46+
}
47+
get(key: TKey): TResult | undefined {
48+
const cacheKey = this._mapKey(key);
49+
return this._map.get(cacheKey);
50+
}
51+
set(key: TKey, value: TResult): void {
52+
const cacheKey = this._mapKey(key);
53+
this._map.set(cacheKey, value);
54+
}
55+
deletePrefix(prefix: KeyPrefix<TKey>): void {
56+
if (this._map.deletePrefix) {
57+
this._map.deletePrefix(prefix as any);
58+
} else if (this._map.keys && typeof prefix === 'string') {
59+
for (const key of this._map.keys()) {
60+
const k: unknown = key;
61+
if (typeof k !== 'string') {
62+
throw new Error(
63+
`This cache contains non-string keys so you cannot use deletePrefix.`,
64+
);
65+
}
66+
if (k.startsWith(prefix)) {
67+
this._map.delete(key);
68+
}
69+
}
70+
} else {
71+
throw new Error(`This cache does not support deletePrefix.`);
72+
}
73+
}
74+
delete(...keys: TKey[]): void {
75+
if (!this._supportsDeleteSpread || keys.length < 2) {
76+
for (const key of keys) {
77+
const cacheKey = this._mapKey(key);
78+
this._map.delete(cacheKey);
79+
}
80+
} else {
81+
const cacheKeys = keys.map(this._mapKey);
82+
this._map.delete(...cacheKeys);
83+
}
84+
}
85+
clear(): void {
86+
if (!this._map.clear) {
87+
throw new Error(`This cache does not support clearing`);
88+
}
89+
this._map.clear();
90+
}
91+
}
92+
export function createCacheMap<TKey, TValue, TMappedKey = TKey>(
93+
map: CacheMapInput<TMappedKey, TValue>,
94+
mapKey: (key: TKey) => TMappedKey,
95+
): CacheMap<TKey, TValue> {
96+
return new CacheMapImplementation(map, mapKey);
97+
}
98+
99+
class AsyncCacheMapImplementation<TKey, TResult, TMappedKey>
100+
extends CacheMapImplementation<TKey, Promise<TResult>, TMappedKey>
101+
implements AsyncCacheMap<TKey, TResult>
102+
{
103+
set(key: TKey, value: TResult | Promise<TResult>): void {
104+
super.set(key, Promise.resolve(value));
105+
}
106+
}
107+
export function createAsyncCacheMap<TKey, TValue, TMappedKey = TKey>(
108+
map: CacheMapInput<TMappedKey, Promise<TValue>>,
109+
mapKey: (key: TKey) => TMappedKey,
110+
): AsyncCacheMap<TKey, TValue> {
111+
return new AsyncCacheMapImplementation(map, mapKey);
112+
}

packages/dataloader/src/MultiKeyMap.ts

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,10 @@
1-
import {CacheMap, CacheMapInput} from './types';
1+
import {CacheMapInput, Path, SubPath} from './types';
22

3-
type Path = readonly [unknown, ...(readonly unknown[])];
4-
type SubPath<TKeys extends readonly unknown[]> = TKeys extends readonly [
5-
...infer THead,
6-
infer TTail,
7-
]
8-
? {readonly [i in keyof TKeys]: TKeys[i]} | SubPath<THead>
9-
: never;
10-
11-
export interface MultiKeyMap<TKeys extends Path, TValue>
12-
extends CacheMap<TKeys, TValue> {
3+
export interface MultiKeyMap<TKeys extends Path, TValue> {
134
readonly size: number;
145
get: (key: TKeys) => TValue | undefined;
156
set: (key: TKeys, value: TValue) => void;
7+
deletePrefix: (key: SubPath<TKeys>) => void;
168
delete: (key: TKeys | SubPath<TKeys>) => void;
179
clear: () => void;
1810
}
@@ -136,6 +128,9 @@ class MultiKeyMapImplementation<TKeys extends Path, TValue, TMappedKey>
136128
set(key: TKeys, value: TValue): void {
137129
this._root.set(key, value);
138130
}
131+
deletePrefix(key: SubPath<TKeys>): void {
132+
this._root.delete(key);
133+
}
139134
delete(key: TKeys | SubPath<TKeys>): void {
140135
this._root.delete(key);
141136
}

0 commit comments

Comments
 (0)