Skip to content

Commit 29ab366

Browse files
committed
feat: add new DataLoaderCache to simplify API
Add new class `DataLoaderCache` to simplify the API. It wraps the `DataLoader` class and automatically uses the `dataloaderCache` when the `cache` option is configured.
1 parent dc5aa74 commit 29ab366

File tree

9 files changed

+1006
-865
lines changed

9 files changed

+1006
-865
lines changed

.changeset/tricky-donkeys-kiss.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@labdigital/dataloader-cache-wrapper': minor
3+
---
4+
5+
Add new class `DataLoaderCache` to simplify the API. It wraps the `DataLoader`
6+
class and automatically uses the `dataloaderCache` when the `cache` option is
7+
configured.

README.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,24 @@
22

33

44
## Usage
5+
```ts
6+
import { DataLoaderCache } from "@labdigital/dataloader-cache-wrapper"
7+
8+
const loader = new DataLoaderCache<ProductReference, any>(ProductDataLoader, {
9+
cache: {
10+
storeFn: () => new Keyv(),
11+
ttl: 3600,
12+
},
13+
cacheKeyFn: (ref: ProdProductReferenceuctRef) => {
14+
const key = `${ref.store}-${ref.locale}-${ref.currency}`;
15+
return `some-data:${key}:id:${ref.slug}`
16+
},
17+
maxBatchSize: 50,
18+
});
19+
20+
```
21+
22+
Or use the older API with the `dataloaderCache` function:
523

624
```ts
725
import { dataloaderCache } from "@labdigital/dataloader-cache-wrapper"
@@ -17,7 +35,7 @@ export const ProductDataLoader = async (keys: readonly any[]): Promise<(Product
1735
store: new Keyv(),
1836
ttl: 3600,
1937

20-
cacheKeysFn: (ref: ProductRef) => {
38+
cacheKeysFn: (ref: ProductReference) => {
2139
const key = `${ref.store}-${ref.locale}-${ref.currency}`;
2240
return [`some-data:${key}:id:${ref.slug}`];
2341
},

package.json

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,13 @@
3737
"tsc": "tsc --noEmit"
3838
},
3939
"devDependencies": {
40-
"@changesets/cli": "2.27.10",
40+
"@changesets/cli": "2.29.2",
4141
"@types/object-hash": "3.0.6",
42-
"@vitest/coverage-v8": "2.1.8",
43-
"dataloader": "^2.2.2",
44-
"tsup": "8.3.5",
45-
"typescript": "5.7.2",
46-
"vitest": "2.1.8"
42+
"@vitest/coverage-v8": "3.1.2",
43+
"dataloader": "^2.2.3",
44+
"tsup": "8.4.0",
45+
"typescript": "5.8.3",
46+
"vitest": "3.1.2"
4747
},
4848
"peerDependencies": {
4949
"dataloader": ">=2.0 <3.0",

pnpm-lock.yaml

Lines changed: 568 additions & 747 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/index.test.ts renamed to src/cache.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import DataLoader from "dataloader";
22
import Keyv from "keyv";
33
import { assert, describe, expect, it } from "vitest";
4-
import { dataloaderCache } from "./index.js";
4+
import { dataloaderCache } from "./cache";
55

66
type MyKey = {
77
id: string;

src/cache.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import type DataLoader from "dataloader";
2+
import type Keyv from "keyv";
3+
import hashObject from "object-hash";
4+
5+
export type NotUndefined = object | string | number | boolean | NotUndefined[];
6+
7+
export type cacheOptions<K, V> = {
8+
keys: ReadonlyArray<K>;
9+
store?: Keyv<V>;
10+
ttl: number;
11+
12+
batchLoadFn: DataLoader.BatchLoadFn<K, V>;
13+
cacheKeysFn: (ref: K) => string[];
14+
};
15+
16+
// dataloaderCache is a wrapper around the dataloader batchLoadFn that adds
17+
// caching. It takes an array of keys and returns an array of items. If an item
18+
// is not in the cache, it will be fetched from the batchLoadFn and then written
19+
// to the cache for next time.
20+
//
21+
// Note: this function is O^2, so it should only be used for small batches of
22+
// keys.
23+
export const dataloaderCache = async <K extends NotUndefined, V>(
24+
args: cacheOptions<K, V | null>,
25+
): Promise<(V | null)[]> => {
26+
const result = await fromCache<K, V>(args.keys, args);
27+
const store: Record<string, V | null> = {};
28+
29+
// Check results, if an item is null then it was not in the cache, we place
30+
// these in the cacheMiss array and fetch them.
31+
const cacheMiss: Array<K> = [];
32+
for (const [key, cached] of zip(args.keys, result)) {
33+
if (cached === undefined) {
34+
cacheMiss.push(key);
35+
} else {
36+
store[hashObject(key)] = cached;
37+
}
38+
}
39+
40+
// Fetch the items that are not in the cache and write them to the cache for
41+
// next time
42+
if (cacheMiss.length > 0) {
43+
const newItems = await args.batchLoadFn(cacheMiss);
44+
const buffer = new Map<string, V | null>();
45+
46+
for (const [key, item] of zip(cacheMiss, Array.from(newItems))) {
47+
if (key === undefined) {
48+
throw new Error("key is undefined");
49+
}
50+
51+
if (!(item instanceof Error)) {
52+
store[hashObject(key)] = item;
53+
54+
const cacheKeys = args.cacheKeysFn(key);
55+
for (const cacheKey of cacheKeys) {
56+
buffer.set(cacheKey, item);
57+
}
58+
}
59+
}
60+
61+
await toCache<K, V | null>(buffer, args);
62+
}
63+
64+
return args.keys.map((key) => {
65+
const item = store[hashObject(key)];
66+
if (item) {
67+
return item;
68+
}
69+
70+
return null;
71+
});
72+
};
73+
74+
// Read items from the cache by the keys
75+
const fromCache = async <K, V>(
76+
keys: ReadonlyArray<K>,
77+
options: cacheOptions<K, V | null>,
78+
): Promise<(V | null | undefined)[]> => {
79+
if (!options.store) {
80+
return new Array<V | null | undefined>(keys.length).fill(undefined);
81+
}
82+
83+
const cacheKeys = keys.flatMap(options.cacheKeysFn);
84+
const cachedValues = await options.store.get(cacheKeys);
85+
return cachedValues.map((v) => v);
86+
};
87+
88+
// Write items to the cache
89+
const toCache = async <K, V>(
90+
items: Map<string, V | null>,
91+
options: cacheOptions<K, V | null>,
92+
): Promise<void> => {
93+
if (!options.store) {
94+
return;
95+
}
96+
for (const [key, value] of items) {
97+
options.store.set(key, value, options.ttl);
98+
}
99+
};
100+
101+
function zip<T, U>(arr1: readonly T[], arr2: readonly U[]): [T, U][] {
102+
const minLength = Math.min(arr1.length, arr2.length);
103+
const result: [T, U][] = [];
104+
105+
for (let i = 0; i < minLength; i++) {
106+
result.push([arr1[i], arr2[i]]);
107+
}
108+
109+
return result;
110+
}

src/index.ts

Lines changed: 3 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -1,110 +1,3 @@
1-
import type DataLoader from "dataloader";
2-
import type Keyv from "keyv";
3-
import hashObject from "object-hash";
4-
5-
type NotUndefined = object | string | number | boolean | NotUndefined[];
6-
7-
export type cacheOptions<K, V> = {
8-
keys: ReadonlyArray<K>;
9-
store?: Keyv<V>;
10-
ttl: number;
11-
12-
batchLoadFn: DataLoader.BatchLoadFn<K, V>;
13-
cacheKeysFn: (ref: K) => string[];
14-
};
15-
16-
// dataloaderCache is a wrapper around the dataloader batchLoadFn that adds
17-
// caching. It takes an array of keys and returns an array of items. If an item
18-
// is not in the cache, it will be fetched from the batchLoadFn and then written
19-
// to the cache for next time.
20-
//
21-
// Note: this function is O^2, so it should only be used for small batches of
22-
// keys.
23-
export const dataloaderCache = async <K extends NotUndefined, V>(
24-
args: cacheOptions<K, V | null>,
25-
): Promise<(V | null)[]> => {
26-
const result = await fromCache<K, V>(args.keys, args);
27-
const store: Record<string, V | null> = {};
28-
29-
// Check results, if an item is null then it was not in the cache, we place
30-
// these in the cacheMiss array and fetch them.
31-
const cacheMiss: Array<K> = [];
32-
for (const [key, cached] of zip(args.keys, result)) {
33-
if (cached === undefined) {
34-
cacheMiss.push(key);
35-
} else {
36-
store[hashObject(key)] = cached;
37-
}
38-
}
39-
40-
// Fetch the items that are not in the cache and write them to the cache for
41-
// next time
42-
if (cacheMiss.length > 0) {
43-
const newItems = await args.batchLoadFn(cacheMiss);
44-
const buffer = new Map<string, V | null>();
45-
46-
for (const [key, item] of zip(cacheMiss, Array.from(newItems))) {
47-
if (key === undefined) {
48-
throw new Error("key is undefined");
49-
}
50-
51-
if (!(item instanceof Error)) {
52-
store[hashObject(key)] = item;
53-
54-
const cacheKeys = args.cacheKeysFn(key);
55-
for (const cacheKey of cacheKeys) {
56-
buffer.set(cacheKey, item);
57-
}
58-
}
59-
}
60-
61-
await toCache<K, V | null>(buffer, args);
62-
}
63-
64-
return args.keys.map((key) => {
65-
const item = store[hashObject(key)];
66-
if (item) {
67-
return item;
68-
}
69-
70-
return null;
71-
});
72-
};
73-
74-
// Read items from the cache by the keys
75-
const fromCache = async <K, V>(
76-
keys: ReadonlyArray<K>,
77-
options: cacheOptions<K, V | null>,
78-
): Promise<(V | null | undefined)[]> => {
79-
if (!options.store) {
80-
return new Array<V | null | undefined>(keys.length).fill(undefined);
81-
}
82-
83-
const cacheKeys = keys.flatMap(options.cacheKeysFn);
84-
const cachedValues = await options.store.get(cacheKeys);
85-
return cachedValues.map((v) => v);
86-
};
87-
88-
// Write items to the cache
89-
const toCache = async <K, V>(
90-
items: Map<string, V | null>,
91-
options: cacheOptions<K, V | null>,
92-
): Promise<void> => {
93-
if (!options.store) {
94-
return;
95-
}
96-
for (const [key, value] of items) {
97-
options.store.set(key, value, options.ttl);
98-
}
99-
};
100-
101-
function zip<T, U>(arr1: readonly T[], arr2: readonly U[]): [T, U][] {
102-
const minLength = Math.min(arr1.length, arr2.length);
103-
const result: [T, U][] = [];
104-
105-
for (let i = 0; i < minLength; i++) {
106-
result.push([arr1[i], arr2[i]]);
107-
}
108-
109-
return result;
110-
}
1+
export { dataloaderCache } from "./cache";
2+
export type { cacheOptions } from "./cache";
3+
export { DataLoaderCache } from "./wrapper";

0 commit comments

Comments
 (0)