diff --git a/.changeset/khaki-falcons-kick.md b/.changeset/khaki-falcons-kick.md new file mode 100644 index 0000000..968c1a5 --- /dev/null +++ b/.changeset/khaki-falcons-kick.md @@ -0,0 +1,5 @@ +--- +'@labdigital/dataloader-cache-wrapper': minor +--- + +Properly support caching null values, and dont handle these as missing diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b45b84..a8ccc20 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3507,7 +3507,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.8.1(@typescript-eslint/parser@5.25.0(eslint@8.57.0)(typescript@5.0.4))(eslint-import-resolver-node@0.3.6)(eslint-import-resolver-typescript@2.7.1(eslint-plugin-import@2.26.0)(eslint@8.57.0))(eslint@8.57.0): + eslint-module-utils@2.8.1(@typescript-eslint/parser@5.25.0(eslint@8.57.0)(typescript@5.0.4))(eslint-import-resolver-node@0.3.6)(eslint-import-resolver-typescript@2.7.1)(eslint@8.57.0): dependencies: debug: 3.2.7 optionalDependencies: @@ -3526,7 +3526,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.0 eslint-import-resolver-node: 0.3.6 - eslint-module-utils: 2.8.1(@typescript-eslint/parser@5.25.0(eslint@8.57.0)(typescript@5.0.4))(eslint-import-resolver-node@0.3.6)(eslint-import-resolver-typescript@2.7.1(eslint-plugin-import@2.26.0)(eslint@8.57.0))(eslint@8.57.0) + eslint-module-utils: 2.8.1(@typescript-eslint/parser@5.25.0(eslint@8.57.0)(typescript@5.0.4))(eslint-import-resolver-node@0.3.6)(eslint-import-resolver-typescript@2.7.1)(eslint@8.57.0) has: 1.0.4 is-core-module: 2.13.1 is-glob: 4.0.3 diff --git a/src/index.test.ts b/src/index.test.ts index d3214f3..9578a2f 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -71,11 +71,16 @@ describe('Test keyv store', () => { const fetchItemsBySlugUncached = async ( keys: readonly MyKey[] - ): Promise<(MyValue | Error)[]> => - keys.map((key) => ({ - slug: key.slug, - name: key.slug, - })) + ): Promise<(MyValue | null | Error)[]> => + keys.map((key) => { + if (key.slug.startsWith('null-')) { + return null + } + return { + slug: key.slug, + name: key.slug, + } + }) const loader = new DataLoader(fetchItemsBySlug, { maxBatchSize: 50, @@ -135,4 +140,71 @@ describe('Test keyv store', () => { expect(cachedValue[2].name).toBe('test-2') } }) + + it('Cache missing product', async () => { + const value = await loader.load({ + id: '1', + slug: 'test-1', + }) + expect(value.slug).toBe('test-1') + expect(value.name).toBe('test-1') + + // This should be one cache hit + { + const cachedValue = await loader.loadMany([ + { + id: '1', + slug: 'test-1', + }, + { + id: '2', + slug: 'test-2', + }, + ]) + expect(cachedValue[0].slug).toBe('test-1') + expect(cachedValue[0].name).toBe('test-1') + + expect(cachedValue[1].slug).toBe('test-2') + expect(cachedValue[1].name).toBe('test-2') + } + + // This should be two cache hits + { + const cachedValue = await loader.loadMany([ + { + id: '1', + slug: 'test-1', + }, + { + id: '3', + slug: 'test-3', + }, + { + id: '2', + slug: 'test-2', + }, + { + id: '4', + slug: 'null-1', + }, + ]) + + expect(cachedValue[0].slug).toBe('test-1') + expect(cachedValue[0].name).toBe('test-1') + + expect(cachedValue[1].slug).toBe('test-3') + expect(cachedValue[1].name).toBe('test-3') + + expect(cachedValue[2].slug).toBe('test-2') + expect(cachedValue[2].name).toBe('test-2') + + expect(cachedValue[3]).toBe(null) + + const storedNull = await store.get('item-data:4:id:null-1') + expect(storedNull).toBeNull() + + const storedUndefined = await store.get('item-data:5:id:null-1') + expect(storedUndefined).toBeUndefined() + } + }) }) diff --git a/src/index.ts b/src/index.ts index 1178cd7..81889c4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,16 +21,16 @@ export type cacheOptions = { // Note: this function is O^2, so it should only be used for small batches of // keys. export const dataloaderCache = async ( - args: cacheOptions + args: cacheOptions ): Promise<(V | null)[]> => { const result = await fromCache(args.keys, args) - const store: Record = {} + const store: Record = {} // Check results, if an item is null then it was not in the cache, we place // these in the cacheMiss array and fetch them. const cacheMiss: Array = [] for (const [key, cached] of zip(args.keys, result)) { - if (cached === null) { + if (cached === undefined) { cacheMiss.push(key) } else { store[hashObject(key)] = cached @@ -41,7 +41,7 @@ export const dataloaderCache = async ( // next time if (cacheMiss.length > 0) { const newItems = await args.batchLoadFn(cacheMiss) - const buffer = new Map() + const buffer = new Map() zip(cacheMiss, Array.from(newItems)).forEach(([key, item]) => { if (key === undefined) { @@ -58,7 +58,7 @@ export const dataloaderCache = async ( } }) - await toCache(buffer, args) + await toCache(buffer, args) } return args.keys.map((key) => { @@ -74,27 +74,31 @@ export const dataloaderCache = async ( // Read items from the cache by the keys const fromCache = async ( keys: ReadonlyArray, - options: cacheOptions -): Promise<(V | null)[]> => { + options: cacheOptions +): Promise<(V | null | undefined)[]> => { if (!options.store) { - return new Array(keys.length).fill(null) + return new Array(keys.length).fill(undefined) } const cacheKeys = keys.flatMap(options.cacheKeysFn) const cachedValues = await options.store.get(cacheKeys) - return cachedValues.map((v) => v ?? null) + return cachedValues.map((v) => v ) } // Write items to the cache const toCache = async ( - items: Map, - options: cacheOptions + items: Map, + options: cacheOptions ): Promise => { if (!options.store) { return } for (const [key, value] of items) { - options.store.set(key, value, options.ttl) + options.store.set( + key, + value, + options.ttl + ) } } diff --git a/tsconfig.json b/tsconfig.json index 19186a1..a2131fa 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,8 +1,7 @@ { "$schema": "https://json.schemastore.org/tsconfig", "compilerOptions": { - "module": "esnext", - "lib": ["esnext"], + "module": "ES2022", "allowJs": true, "importHelpers": true, "declaration": true, @@ -12,8 +11,7 @@ "noFallthroughCasesInSwitch": true, "noUnusedLocals": false, "noUnusedParameters": false, - "moduleResolution": "node", - "jsx": "react", + "moduleResolution": "bundler", "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true,