diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 544b78b..b412471 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -35,7 +35,7 @@ jobs: with: node-version: ${{ matrix.node }} - run: npm install - - run: npx nyc --reporter=lcov aegir test -t node -- --bail + - run: npx aegir test -t node --cov --bail - uses: codecov/codecov-action@v1 test-chrome: needs: check @@ -50,7 +50,7 @@ jobs: steps: - uses: actions/checkout@v2 - run: npm install - - run: npx aegir test -t browser -t webworker --bail -- --browsers FirefoxHeadless + - run: npx aegir test -t browser -t webworker --bail -- --browser firefox test-electron-main: needs: check runs-on: ubuntu-latest diff --git a/package.json b/package.json index 9e53f8c..b9bb1c2 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "dist" ], "scripts": { - "prepare": "aegir build --no-bundle && cp src/types.d.ts dist/src/types.d.ts", + "prepare": "aegir build --no-bundle", "lint": "aegir lint", "test": "aegir test", "test:node": "aegir test --target node", @@ -18,8 +18,7 @@ "release": "aegir release --docs", "release-minor": "aegir release --type minor --docs", "release-major": "aegir release --type major --docs", - "coverage": "aegir coverage", - "coverage-publish": "aegir coverage --provider codecov", + "coverage": "aegir test --cov", "docs": "aegir docs" }, "repository": { @@ -39,7 +38,8 @@ "homepage": "https://github.com/ipfs/interface-datastore#readme", "devDependencies": { "@types/err-code": "^2.0.0", - "aegir": "^33.1.0" + "aegir": "^33.1.0", + "it-map": "^1.0.5" }, "dependencies": { "err-code": "^3.0.1", @@ -47,7 +47,10 @@ "iso-random-stream": "^2.0.0", "it-all": "^1.0.2", "it-drain": "^1.0.1", - "nanoid": "^3.0.2" + "it-filter": "^1.0.2", + "it-take": "^1.0.1", + "nanoid": "^3.0.2", + "uint8arrays": "^2.1.5" }, "eslintConfig": { "extends": "ipfs" diff --git a/src/adapter.js b/src/adapter.js index a36e095..9591fc6 100644 --- a/src/adapter.js +++ b/src/adapter.js @@ -1,7 +1,9 @@ 'use strict' -const { filter, sortAll, take, map } = require('./utils') +const { sortAll } = require('./utils') const drain = require('it-drain') +const filter = require('it-filter') +const take = require('it-take') /** * @typedef {import('./key')} Key @@ -9,6 +11,7 @@ const drain = require('it-drain') * @typedef {import('./types').Datastore} Datastore * @typedef {import('./types').Options} Options * @typedef {import('./types').Query} Query + * @typedef {import('./types').KeyQuery} KeyQuery * @typedef {import('./types').Batch} Batch */ @@ -134,6 +137,8 @@ class Adapter { } /** + * Extending classes should override `query` or implement this method + * * @param {Query} q * @param {Options} [options] * @returns {AsyncIterable} @@ -143,6 +148,18 @@ class Adapter { throw new Error('._all is not implemented') } + /** + * Extending classes should override `queryKeys` or implement this method + * + * @param {KeyQuery} q + * @param {Options} [options] + * @returns {AsyncIterable} + */ + // eslint-disable-next-line require-yield + async * _allKeys (q, options) { + throw new Error('._allKeys is not implemented') + } + /** * @param {Query} q * @param {Options} [options] @@ -173,8 +190,37 @@ class Adapter { it = take(it, q.limit) } - if (q.keysOnly === true) { - return map(it, (e) => /** @type {Pair} */({ key: e.key })) + return it + } + + /** + * @param {KeyQuery} q + * @param {Options} [options] + */ + queryKeys (q, options) { + let it = this._allKeys(q, options) + + if (q.prefix != null) { + it = filter(it, (key) => + key.toString().startsWith(/** @type {string} */ (q.prefix)) + ) + } + + if (Array.isArray(q.filters)) { + it = q.filters.reduce((it, f) => filter(it, f), it) + } + + if (Array.isArray(q.orders)) { + it = q.orders.reduce((it, f) => sortAll(it, f), it) + } + + if (q.offset != null) { + let i = 0 + it = filter(it, () => i++ >= /** @type {number} */ (q.offset)) + } + + if (q.limit != null) { + it = take(it, q.limit) } return it diff --git a/src/index.js b/src/index.js index f953ace..6d1c673 100644 --- a/src/index.js +++ b/src/index.js @@ -5,6 +5,11 @@ * @typedef {import('./types').Batch} Batch * @typedef {import('./types').Options} Options * @typedef {import('./types').Query} Query + * @typedef {import('./types').QueryFilter} QueryFilter + * @typedef {import('./types').QueryOrder} QueryOrder + * @typedef {import('./types').KeyQuery} KeyQuery + * @typedef {import('./types').KeyQueryFilter} KeyQueryFilter + * @typedef {import('./types').KeyQueryOrder} KeyQueryOrder * @typedef {import('./types').Pair} Pair */ diff --git a/src/key.js b/src/key.js index 3a378c3..e4ee4b7 100644 --- a/src/key.js +++ b/src/key.js @@ -1,11 +1,13 @@ 'use strict' const { nanoid } = require('nanoid') -const { utf8Encoder, utf8Decoder } = require('./utils') + +const uint8ArrayToString = require('uint8arrays/to-string') +const uint8ArrayFromString = require('uint8arrays/from-string') const symbol = Symbol.for('@ipfs/interface-datastore/key') const pathSepS = '/' -const pathSepB = utf8Encoder.encode(pathSepS) +const pathSepB = new TextEncoder().encode(pathSepS) const pathSep = pathSepB[0] /** @@ -31,7 +33,7 @@ class Key { */ constructor (s, clean) { if (typeof s === 'string') { - this._buf = utf8Encoder.encode(s) + this._buf = uint8ArrayFromString(s) } else if (s instanceof Uint8Array) { this._buf = s } else { @@ -54,15 +56,11 @@ class Key { /** * Convert to the string representation * - * @param {string} [encoding='utf8'] - The encoding to use. + * @param {import('uint8arrays/to-string').SupportedEncodings} [encoding='utf8'] - The encoding to use. * @returns {string} */ toString (encoding = 'utf8') { - if (encoding === 'utf8' || encoding === 'utf-8') { - return utf8Decoder.decode(this._buf) - } - - return new TextDecoder(encoding).decode(this._buf) + return uint8ArrayToString(this._buf, encoding) } /** diff --git a/src/memory.js b/src/memory.js index 81a8c71..d9a2caa 100644 --- a/src/memory.js +++ b/src/memory.js @@ -65,6 +65,11 @@ class MemoryDatastore extends Adapter { yield * Object.entries(this.data) .map(([key, value]) => ({ key: new Key(key), value })) } + + async * _allKeys () { + yield * Object.entries(this.data) + .map(([key]) => new Key(key)) + } } module.exports = MemoryDatastore diff --git a/src/tests.js b/src/tests.js index 0a809cf..8a676b2 100644 --- a/src/tests.js +++ b/src/tests.js @@ -1,18 +1,21 @@ /* eslint-env mocha */ 'use strict' -// @ts-ignore -const randomBytes = require('iso-random-stream/src/random') +const { randomBytes } = require('iso-random-stream') const { expect } = require('aegir/utils/chai') const all = require('it-all') const drain = require('it-drain') -const { utf8Encoder } = require('../src/utils') +const uint8ArrayFromString = require('uint8arrays/from-string') const { Key } = require('../src') /** * @typedef {import('./types').Datastore} Datastore * @typedef {import('./types').Pair} Pair + * @typedef {import('./types').QueryOrder} QueryOrder + * @typedef {import('./types').QueryFilter} QueryFilter + * @typedef {import('./types').KeyQueryOrder} KeyQueryOrder + * @typedef {import('./types').KeyQueryFilter} KeyQueryFilter */ /** @@ -46,13 +49,13 @@ module.exports = (test) => { it('simple', () => { const k = new Key('/z/one') - return store.put(k, utf8Encoder.encode('one')) + return store.put(k, uint8ArrayFromString('one')) }) it('parallel', async () => { const data = [] for (let i = 0; i < 100; i++) { - data.push({ key: new Key(`/z/key${i}`), value: utf8Encoder.encode(`data${i}`) }) + data.push({ key: new Key(`/z/key${i}`), value: uint8ArrayFromString(`data${i}`) }) } await Promise.all(data.map(d => store.put(d.key, d.value))) @@ -75,7 +78,7 @@ module.exports = (test) => { it('streaming', async () => { const data = [] for (let i = 0; i < 100; i++) { - data.push({ key: new Key(`/z/key${i}`), value: utf8Encoder.encode(`data${i}`) }) + data.push({ key: new Key(`/z/key${i}`), value: uint8ArrayFromString(`data${i}`) }) } let index = 0 @@ -104,9 +107,9 @@ module.exports = (test) => { it('simple', async () => { const k = new Key('/z/one') - await store.put(k, utf8Encoder.encode('hello')) + await store.put(k, uint8ArrayFromString('hello')) const res = await store.get(k) - expect(res).to.be.eql(utf8Encoder.encode('hello')) + expect(res).to.be.eql(uint8ArrayFromString('hello')) }) it('should throw error for missing key', async () => { @@ -135,12 +138,12 @@ module.exports = (test) => { it('streaming', async () => { const k = new Key('/z/one') - await store.put(k, utf8Encoder.encode('hello')) + await store.put(k, uint8ArrayFromString('hello')) const source = [k] const res = await all(store.getMany(source)) expect(res).to.have.lengthOf(1) - expect(res[0]).to.be.eql(utf8Encoder.encode('hello')) + expect(res[0]).to.be.eql(uint8ArrayFromString('hello')) }) it('should throw error for missing key', async () => { @@ -169,7 +172,7 @@ module.exports = (test) => { it('simple', async () => { const k = new Key('/z/one') - await store.put(k, utf8Encoder.encode('hello')) + await store.put(k, uint8ArrayFromString('hello')) await store.get(k) await store.delete(k) const exists = await store.has(k) @@ -180,7 +183,7 @@ module.exports = (test) => { /** @type {[Key, Uint8Array][]} */ const data = [] for (let i = 0; i < 100; i++) { - data.push([new Key(`/a/key${i}`), utf8Encoder.encode(`data${i}`)]) + data.push([new Key(`/a/key${i}`), uint8ArrayFromString(`data${i}`)]) } await Promise.all(data.map(d => store.put(d[0], d[1]))) @@ -208,7 +211,7 @@ module.exports = (test) => { it('streaming', async () => { const data = [] for (let i = 0; i < 100; i++) { - data.push({ key: new Key(`/a/key${i}`), value: utf8Encoder.encode(`data${i}`) }) + data.push({ key: new Key(`/a/key${i}`), value: uint8ArrayFromString(`data${i}`) }) } await drain(store.putMany(data)) @@ -243,11 +246,11 @@ module.exports = (test) => { it('simple', async () => { const b = store.batch() - await store.put(new Key('/z/old'), utf8Encoder.encode('old')) + await store.put(new Key('/z/old'), uint8ArrayFromString('old')) - b.put(new Key('/a/one'), utf8Encoder.encode('1')) - b.put(new Key('/q/two'), utf8Encoder.encode('2')) - b.put(new Key('/q/three'), utf8Encoder.encode('3')) + b.put(new Key('/a/one'), uint8ArrayFromString('1')) + b.put(new Key('/q/two'), uint8ArrayFromString('2')) + b.put(new Key('/q/three'), uint8ArrayFromString('3')) b.delete(new Key('/z/old')) await b.commit() @@ -288,44 +291,41 @@ module.exports = (test) => { describe('query', () => { /** @type {Datastore} */ let store - const hello = { key: new Key('/q/1hello'), value: utf8Encoder.encode('1') } - const world = { key: new Key('/z/2world'), value: utf8Encoder.encode('2') } - const hello2 = { key: new Key('/z/3hello2'), value: utf8Encoder.encode('3') } + const hello = { key: new Key('/q/1hello'), value: uint8ArrayFromString('1') } + const world = { key: new Key('/z/2world'), value: uint8ArrayFromString('2') } + const hello2 = { key: new Key('/z/3hello2'), value: uint8ArrayFromString('3') } /** - * @param {Pair} entry + * @type {QueryFilter} */ const filter1 = entry => !entry.key.toString().endsWith('hello') + /** - * @param {Pair} entry + * @type {QueryFilter} */ const filter2 = entry => entry.key.toString().endsWith('hello2') /** - * @param {Pair[]} res + * @type {QueryOrder} */ - const order1 = res => { - return res.sort((a, b) => { - if (a.value.toString() < b.value.toString()) { - return -1 - } - return 1 - }) + const order1 = (a, b) => { + if (a.value.toString() < b.value.toString()) { + return -1 + } + return 1 } /** - * @param {Pair[]} res + * @type {QueryOrder} */ - const order2 = res => { - return res.sort((a, b) => { - if (a.value.toString() < b.value.toString()) { - return 1 - } - if (a.value.toString() > b.value.toString()) { - return -1 - } - return 0 - }) + const order2 = (a, b) => { + if (a.value.toString() < b.value.toString()) { + return 1 + } + if (a.value.toString() > b.value.toString()) { + return -1 + } + return 0 } /** @type {Array<[string, any, any[]|number]>} */ @@ -336,7 +336,6 @@ module.exports = (test) => { ['2 filters', { filters: [filter1, filter2] }, [hello2]], ['limit', { limit: 1 }, 1], ['offset', { offset: 1 }, 2], - ['keysOnly', { keysOnly: true }, [{ key: hello.key }, { key: world.key }, { key: hello2.key }]], ['1 order (1)', { orders: [order1] }, [hello, world, hello2]], ['1 order (reverse 1)', { orders: [order2] }, [hello2, world, hello]] ] @@ -393,7 +392,7 @@ module.exports = (test) => { })) it('allows mutating the datastore during a query', async () => { - const hello3 = { key: new Key('/z/4hello3'), value: utf8Encoder.encode('4') } + const hello3 = { key: new Key('/z/4hello3'), value: uint8ArrayFromString('4') } let firstIteration = true for await (const {} of store.query({})) { // eslint-disable-line no-empty-pattern @@ -418,13 +417,142 @@ module.exports = (test) => { }) it('queries while the datastore is being mutated', async () => { - const writePromise = store.put(new Key(`/z/key-${Math.random()}`), utf8Encoder.encode('0')) + const writePromise = store.put(new Key(`/z/key-${Math.random()}`), uint8ArrayFromString('0')) const results = await all(store.query({})) expect(results.length).to.be.greaterThan(0) await writePromise }) }) + describe('queryKeys', () => { + /** @type {Datastore} */ + let store + const hello = { key: new Key('/q/1hello'), value: uint8ArrayFromString('1') } + const world = { key: new Key('/z/2world'), value: uint8ArrayFromString('2') } + const hello2 = { key: new Key('/z/3hello2'), value: uint8ArrayFromString('3') } + + /** + * @type {KeyQueryFilter} + */ + const filter1 = key => !key.toString().endsWith('hello') + + /** + * @type {KeyQueryFilter} + */ + const filter2 = key => key.toString().endsWith('hello2') + + /** + * @type {KeyQueryOrder} + */ + const order1 = (a, b) => { + if (a.toString() < b.toString()) { + return -1 + } + return 1 + } + + /** + * @type {KeyQueryOrder} + */ + const order2 = (a, b) => { + if (a.toString() < b.toString()) { + return 1 + } + if (a.toString() > b.toString()) { + return -1 + } + return 0 + } + + /** @type {Array<[string, any, any[]|number]>} */ + const tests = [ + ['empty', {}, [hello.key, world.key, hello2.key]], + ['prefix', { prefix: '/z' }, [world.key, hello2.key]], + ['1 filter', { filters: [filter1] }, [world.key, hello2.key]], + ['2 filters', { filters: [filter1, filter2] }, [hello2.key]], + ['limit', { limit: 1 }, 1], + ['offset', { offset: 1 }, 2], + ['1 order (1)', { orders: [order1] }, [hello.key, world.key, hello2.key]], + ['1 order (reverse 1)', { orders: [order2] }, [hello2.key, world.key, hello.key]] + ] + + before(async () => { + store = await createStore() + + const b = store.batch() + + b.put(hello.key, hello.value) + b.put(world.key, world.value) + b.put(hello2.key, hello2.value) + + return b.commit() + }) + + after(() => cleanup(store)) + + tests.forEach(([name, query, expected]) => it(name, async () => { + let res = await all(store.queryKeys(query)) + + if (Array.isArray(expected)) { + if (query.orders == null) { + expect(res).to.have.length(expected.length) + /** + * @type {KeyQueryOrder} + */ + const s = (a, b) => { + if (a.toString() < b.toString()) { + return 1 + } else { + return -1 + } + } + res = res.sort(s) + const exp = expected.sort(s) + + res.forEach((r, i) => { + expect(r.toString()).to.be.eql(exp[i].toString()) + }) + } else { + expect(res).to.be.eql(expected) + } + } else if (typeof expected === 'number') { + expect(res).to.have.length(expected) + } + })) + + it('allows mutating the datastore during a query', async () => { + const hello3 = { key: new Key('/z/4hello3'), value: uint8ArrayFromString('4') } + let firstIteration = true + + for await (const {} of store.queryKeys({})) { // eslint-disable-line no-empty-pattern + if (firstIteration) { + expect(await store.has(hello2.key)).to.be.true() + await store.delete(hello2.key) + expect(await store.has(hello2.key)).to.be.false() + + await store.put(hello3.key, hello3.value) + firstIteration = false + } + } + + const results = await all(store.queryKeys({})) + + expect(firstIteration).to.be.false('Query did not return anything') + expect(results).to.have.deep.members([ + hello.key, + world.key, + hello3.key + ]) + }) + + it('queries while the datastore is being mutated', async () => { + const writePromise = store.put(new Key(`/z/key-${Math.random()}`), uint8ArrayFromString('0')) + const results = await all(store.queryKeys({})) + expect(results.length).to.be.greaterThan(0) + await writePromise + }) + }) + describe('lifecycle', () => { /** @type {Datastore} */ let store diff --git a/src/types.d.ts b/src/types.d.ts index 267932b..b66f0e5 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -137,22 +137,49 @@ export interface Datastore { * * @example * ```js - * // retrieve __all__ values from the store + * // retrieve __all__ key/value pairs from the store * let list = [] - * for await (const value of store.query({})) { + * for await (const { key, value } of store.query({})) { * list.push(value) * } * console.log('ALL THE VALUES', list) * ``` */ - query: (q: Query, options?: Options) => AsyncIterable + query: (query: Query, options?: Options) => AsyncIterable + /** + * Query the store. + * + * @example + * ```js + * // retrieve __all__ keys from the store + * let list = [] + * for await (const key of store.queryKeys({})) { + * list.push(key) + * } + * console.log('ALL THE KEYS', key) + * ``` + */ + queryKeys: (query: KeyQuery, options?: Options) => AsyncIterable } +export type QueryFilter = (item: Pair) => boolean +export type QueryOrder = (a: Pair, b: Pair) => -1 | 0 | 1 + export interface Query { prefix?: string - filters?: Array<(item: Pair) => boolean> - orders?: Array<(items: Pair[]) => Await> + filters?: QueryFilter[] + orders?: QueryOrder[] + limit?: number + offset?: number +} + +export type KeyQueryFilter = (item: Key) => boolean +export type KeyQueryOrder = (a: Key, b: Key) => -1 | 0 | 1 + +export interface KeyQuery { + prefix?: string + filters?: KeyQueryFilter[] + orders?: KeyQueryOrder[] limit?: number offset?: number - keysOnly?: boolean } diff --git a/src/utils.js b/src/utils.js index 303ad71..898a0c2 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,6 +1,7 @@ 'use strict' const tempdir = require('ipfs-utils/src/temp-dir') +const all = require('it-all') /** * @template T @@ -12,77 +13,19 @@ const tempdir = require('ipfs-utils/src/temp-dir') * @typedef {import("./types").AwaitIterable} AnyIterable */ -const utf8Encoder = new TextEncoder() -const utf8Decoder = new TextDecoder('utf8') - -/** - * Filter - * - * @template T - * @param {AnyIterable} iterable - * @param {(item: T) => PromiseOrValue} filterer - * @returns {AsyncIterable} - */ -const filter = (iterable, filterer) => { - return (async function * () { - for await (const value of iterable) { - const keep = await filterer(value) - if (!keep) continue - yield value - } - })() -} - -// Not just sort, because the sorter is given all the values and should return -// them all sorted /** - * Sort All + * Collect all values from the iterable and sort them using + * the passed sorter function * * @template T * @param {AnyIterable} iterable - * @param {(items: T[]) => PromiseOrValue} sorter + * @param {(a: T, b: T) => -1 | 0 | 1} sorter * @returns {AsyncIterable} */ const sortAll = (iterable, sorter) => { return (async function * () { - let values = [] - for await (const value of iterable) values.push(value) - values = await sorter(values) - for (const value of values) yield value - })() -} - -/** - * - * @template T - * @param {AsyncIterable | Iterable} iterable - * @param {number} n - * @returns {AsyncIterable} - */ -const take = (iterable, n) => { - return (async function * () { - if (n <= 0) return - let i = 0 - for await (const value of iterable) { - yield value - i++ - if (i >= n) return - } - })() -} - -/** - * - * @template T,O - * @param {AsyncIterable | Iterable} iterable - * @param {(item: T) => O} mapper - * @returns {AsyncIterable} - */ -const map = (iterable, mapper) => { - return (async function * () { - for await (const value of iterable) { - yield mapper(value) - } + const values = await all(iterable) + yield * values.sort(sorter) })() } @@ -96,12 +39,7 @@ const replaceStartWith = (s, r) => { } module.exports = { - map, - take, sortAll, - filter, - utf8Encoder, - utf8Decoder, tmpdir: tempdir, replaceStartWith } diff --git a/test/utils.spec.js b/test/utils.spec.js index 6f3bfe9..696ea75 100644 --- a/test/utils.spec.js +++ b/test/utils.spec.js @@ -3,6 +3,9 @@ const { expect } = require('aegir/utils/chai') const utils = require('../src').utils +const filter = require('it-filter') +const take = require('it-take') +const map = require('it-map') describe('utils', () => { it('filter - sync', async () => { @@ -12,7 +15,7 @@ describe('utils', () => { */ const filterer = val => val % 2 === 0 const res = [] - for await (const val of utils.filter(data, filterer)) { + for await (const val of filter(data, filterer)) { res.push(val) } expect(res).to.be.eql([2, 4]) @@ -25,7 +28,7 @@ describe('utils', () => { */ const filterer = val => val % 2 === 0 const res = [] - for await (const val of utils.filter(data, filterer)) { + for await (const val of filter(data, filterer)) { res.push(val) } expect(res).to.be.eql([2, 4]) @@ -34,9 +37,20 @@ describe('utils', () => { it('sortAll', async () => { const data = [1, 2, 3, 4] /** - * @param {number[]} vals + * @param {number} a + * @param {number} b */ - const sorter = vals => vals.reverse() + const sorter = (a, b) => { + if (a < b) { + return 1 + } + + if (a > b) { + return -1 + } + + return 0 + } const res = [] for await (const val of utils.sortAll(data, sorter)) { res.push(val) @@ -65,7 +79,7 @@ describe('utils', () => { const data = [1, 2, 3, 4] const n = 3 const res = [] - for await (const val of utils.take(data, n)) { + for await (const val of take(data, n)) { res.push(val) } expect(res).to.be.eql([1, 2, 3]) @@ -74,7 +88,7 @@ describe('utils', () => { it('should take nothing from iterator', async () => { const data = [1, 2, 3, 4] const n = 0 - for await (const _ of utils.take(data, n)) { // eslint-disable-line + for await (const _ of take(data, n)) { // eslint-disable-line throw new Error('took a value') } }) @@ -86,7 +100,7 @@ describe('utils', () => { */ const mapper = n => n * 2 const res = [] - for await (const val of utils.map(data, mapper)) { + for await (const val of map(data, mapper)) { res.push(val) } expect(res).to.be.eql([2, 4, 6, 8]) diff --git a/tsconfig.json b/tsconfig.json index e605b61..fe3cbd6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,10 +1,10 @@ { - "extends": "./node_modules/aegir/src/config/tsconfig.aegir.json", + "extends": "aegir/src/config/tsconfig.aegir.json", "compilerOptions": { "outDir": "dist" }, "include": [ - "test", // remove this line if you don't want to type-check tests + "test", "src" ] }