aloedb-node
Advanced tools
Comparing version 1.0.2 to 1.1.0
{ | ||
"name": "aloedb-node", | ||
"version": "1.0.2", | ||
"description": "Light local storage database for NodeJS", | ||
"main": "dist/index.js", | ||
"types": "dist/index.d.ts", | ||
"files": [ | ||
"/dist" | ||
], | ||
"directories": { | ||
"lib": "lib" | ||
}, | ||
"keywords": [ | ||
"database", | ||
"db", | ||
"json", | ||
"storage", | ||
"local", | ||
"type", | ||
"typescript" | ||
], | ||
"engines": { | ||
"node": "^15.5.0" | ||
}, | ||
"scripts": { | ||
"serve": "nodemon" | ||
}, | ||
"repository": { | ||
"type": "git", | ||
"url": "git+https://github.com/ElectroGamez/AloeDB.git" | ||
}, | ||
"author": "Wouter de Bruijn", | ||
"license": "MIT", | ||
"bugs": { | ||
"url": "https://github.com/ElectroGamez/AloeDB/issues" | ||
}, | ||
"homepage": "https://github.com/ElectroGamez/AloeDB#readme", | ||
"devDependencies": { | ||
"@types/node": "^14.14.33" | ||
} | ||
"name": "aloedb-node", | ||
"version": "1.1.0", | ||
"description": "Light, Embeddable, NoSQL database ported to NodeJS", | ||
"main": "dist/index.js", | ||
"types": "dist/index.d.ts", | ||
"scripts": { | ||
"build": "tsc" | ||
}, | ||
"files": [ | ||
"dist" | ||
], | ||
"repository": { | ||
"type": "git", | ||
"url": "git+https://github.com/wouterdebruijn/AloeDB-node.git" | ||
}, | ||
"keywords": [ | ||
"Database", | ||
"DB", | ||
"JSON" | ||
], | ||
"author": "Wouter de Bruijn", | ||
"license": "MIT", | ||
"bugs": { | ||
"url": "https://github.com/wouterdebruijn/AloeDB-node/issues" | ||
}, | ||
"homepage": "https://github.com/wouterdebruijn/AloeDB-node#readme", | ||
"devDependencies": { | ||
"@types/node": "^18.11.12", | ||
"typescript": "^4.9.5" | ||
} | ||
} |
592
README.md
@@ -5,8 +5,5 @@ <p align="center"> | ||
<h3 align="center">AloeDB-Node</h3> | ||
<p align="center"><i>Light local storage database for NodeJS</i></p> | ||
<p align="center"> | ||
<b>Work in progress of a Work in progress!</b><br> | ||
<span>Forked from <a href="https://github.com/Kirlovon/AloeDB">Kirlovon their Deno package!</a></span> | ||
<h3 align="center">AloeDB - NodeJS</h3> | ||
<p align="center"><i>Light, Embeddable, NoSQL database ported to NodeJS</i></p> | ||
</p> | ||
@@ -16,184 +13,513 @@ | ||
## Port | ||
## ⚗️ Ported version | ||
This is a forked version of [AloeDB](https://github.com/Kirlovon/AloeDB) by [Kirlovon](https://github.com/Kirlovon). Changed to be able to be used in a modern NodeJS environment. | ||
This is a port of the Deno package: https://github.com/Kirlovon/AloeDB | ||
<br> | ||
## Features | ||
<br> | ||
- ✨ Simple to use API, similar to [MongoDB](https://www.mongodb.com/)! | ||
- 🚀 Optimized for a large number of operations. | ||
- ⚖ No dependencies! | ||
- 📁 Stores data in readable JSON file. | ||
## ✨ Features | ||
* 🎉 Simple to use API, similar to [MongoDB](https://www.mongodb.com/)! | ||
* 🚀 Optimized for a large number of operations. | ||
* ⚖ No dependencies, even without [std](https://deno.land/std)! | ||
* 📁 Stores data in readable JSON file. | ||
<br> | ||
## Importing | ||
## 📦 Importing | ||
```typescript | ||
import { Database } from "aloedb-node"; | ||
import { Database } from 'aloedb-node' | ||
``` | ||
## Examples | ||
### Using an interface | ||
<br> | ||
## 📖 Example | ||
```typescript | ||
import { Database } from "aloedb-node"; | ||
import { Database } from 'aloedb-node' | ||
// Structure of stored documents | ||
interface Film { | ||
title: string; | ||
year: number; | ||
film: boolean; | ||
genres: string[]; | ||
authors: { director: string }; | ||
title: string; | ||
year: number; | ||
film: boolean; | ||
genres: string[]; | ||
authors: { director: string }; | ||
} | ||
(async () => { | ||
// Initialization | ||
const db = new Database<Film>("./path/to/the/file.json"); | ||
// Initialization | ||
const db = new Database<Film>('./path/to/the/file.json'); | ||
// Insert operations | ||
await db.insertOne({ | ||
title: "Drive", | ||
year: 2012, | ||
film: true, | ||
genres: ["crime", "drama", "noir"], | ||
authors: { director: "Nicolas Winding Refn" }, | ||
}); | ||
// Insert operations | ||
await db.insertOne({ | ||
title: 'Drive', | ||
year: 2012, | ||
film: true, | ||
genres: ['crime', 'drama', 'noir'], | ||
authors: { director: 'Nicolas Winding Refn' } | ||
}); | ||
// Search operations | ||
const found = await db.findOne({ title: "Drive", film: true }); | ||
console.log(found); | ||
// Search operations | ||
const found = await db.findOne({ title: 'Drive', film: true }); | ||
// Update operations | ||
await db.updateOne({ title: "Drive" }, { year: 2011 }); | ||
// Update operations | ||
await db.updateOne({ title: 'Drive' }, { year: 2011 }); | ||
// Delete operations | ||
await db.deleteOne({ title: "Drive" }); | ||
})(); | ||
// Delete operations | ||
await db.deleteOne({ title: 'Drive' }); | ||
``` | ||
_P.S. You can find more examples [here](https://github.com/Kirlovon/AloeDB/tree/master/examples)!_ | ||
### Class without a separate interface (Also using uuids and timestamps) | ||
```typescript | ||
import { Database } from "aloedb-node"; | ||
import { v1 as uuidv1 } from "uuid"; | ||
const db = new Database<Omit<Weather, "save" | "delete" | "update">>("./db/weather.json"); | ||
<br> | ||
export class Weather { | ||
id: string; | ||
timestamp: number; | ||
temperature: number; | ||
humidity: number; | ||
## 🏃 Benchmarks | ||
This database is not aimed at a heavily loaded backend, but its speed should be good enough for small APIs working with less than a million documents. | ||
constructor(data: Omit<Weather, "save" | "delete" | "update" | "id" | "timestamp"> & Partial<Pick<Weather, "id" | "timestamp">>) { | ||
this.id = data.id ?? uuidv1(); | ||
this.timestamp = data.timestamp ?? new Date().getTime(); | ||
this.temperature = data.temperature; | ||
this.humidity = data.humidity; | ||
} | ||
To give you an example, here is the speed of a database operations with *1000* documents: | ||
save() { | ||
return db.insertOne(this); | ||
} | ||
| Insertion | Searching | Updating | Deleting | | ||
| ------------- | ------------- | ------------- | ------------- | | ||
| 15k _ops/sec_ | 65k _ops/sec_ | 8k _ops/sec_ | 10k _ops/sec_ | | ||
delete() { | ||
return db.deleteOne({ id: this.id }); | ||
} | ||
<br> | ||
update() { | ||
return db.updateOne({ id: this.id }, this); | ||
} | ||
## 📚 Guide | ||
static async findOne(query: Partial<Omit<Weather, "save" | "delete" | "update">>): Promise<Weather | null> { | ||
const object = await db.findOne(query); | ||
if (object) return new Weather(object); | ||
return null; | ||
} | ||
### Initialization | ||
```typescript | ||
import { Database } from 'aloedb-node' | ||
static async findMany(query: Partial<Omit<Weather, "save" | "delete" | "update">>): Promise<Weather[]> { | ||
const objects = await db.findMany(query); | ||
interface Schema { | ||
username: string; | ||
password: string; | ||
} | ||
return objects.map((obj) => { | ||
return new Weather(obj); | ||
}); | ||
} | ||
} | ||
const db = new Database<Schema>({ | ||
path: './data.json', | ||
pretty: true, | ||
autoload: true, | ||
autosave: true, | ||
optimize: true, | ||
immutable: true, | ||
validator: (document: any) => {} | ||
}); | ||
``` | ||
The following fields are available for configuration: | ||
* `path` - Path to the database file. If undefined, data will be stored only in-memory. _(Default: undefined)_ | ||
* `pretty` - Save data in easy-to-read format. _(Default: true)_ | ||
* `autoload` - Automatically load the file synchronously when initializing the database. _(Default: true)_ | ||
* `autosave` - Automatically save data to the file after inserting, updating and deleting documents. _(Default: true)_ | ||
* `optimize` - Optimize data writing. If enabled, the data will be written many times faster in case of a large number of operations. _(Default: true)_ | ||
* `immutable` - Automatically deeply clone all returned objects. _(Default: true)_ | ||
* `validator` - Runtime documents validation function. If the document does not pass the validation, just throw the error. | ||
### Class with seperate Interface | ||
Also, you can initialize the database in the following ways: | ||
```typescript | ||
import { Database } from "aloedb-node"; | ||
import { v1 as uuidv1 } from "uuid"; | ||
// In-memory database | ||
const db = new Database(); | ||
``` | ||
const db = new Database<IUser>("./db/user.json"); | ||
```typescript | ||
// Short notation, specifying the file path only | ||
const db = new Database('./path/to/the/file.json'); | ||
``` | ||
interface IUser { | ||
id?: string; | ||
email: string; | ||
hash: string; | ||
<br> | ||
### Typization | ||
AloeDB allows you to specify the schema of documents. | ||
By doing this, you will get auto-completion and types validation. This is a **completely optional** feature that can make it easier for you to work with the database. | ||
Just specify an interface that contains only the types supported by the database _(strings, numbers, booleans, nulls, array, objects)_, and everything will works like magic! 🧙 | ||
```typescript | ||
// Your schema | ||
interface User { | ||
username: string; | ||
password: string; | ||
} | ||
export class User { | ||
id: string; | ||
email: string; | ||
hash: string; | ||
// Initialize a database with a specific schema | ||
const db = new Database<User>(); | ||
constructor(data: IUser) { | ||
this.id = data.id ?? uuidv1(); | ||
this.email = data.email; | ||
this.hash = data.hash; | ||
} | ||
await db.insertOne({ username: 'bob', password: 'qwerty' }); // Ok 👌 | ||
await db.insertOne({ username: 'greg' }); // Error: Property 'password' is missing | ||
``` | ||
save() { | ||
return db.insertOne(this); | ||
} | ||
<br> | ||
delete() { | ||
return db.deleteOne({ id: this.id }); | ||
} | ||
### Inserting | ||
AloeDB is a document-oriented database, so you storing objects in it. The supported types are **Strings**, **Numbers**, **Booleans**, **Nulls**, **Arrays** & **Objects**. | ||
update() { | ||
return db.updateOne({ id: this.id }, this); | ||
} | ||
Keep in mind that data types such as **Date**, **Map**, **Set** and other complex types are not supported, and all fields with them will be deleted. Also, any blank documents will not be inserted. | ||
static async findOne(query: Partial<IUser>): Promise<User | null> { | ||
const object = await db.findOne(query); | ||
if (object) return new User(object); | ||
return null; | ||
} | ||
```typescript | ||
const inserted = await db.insertOne({ text: 'Hey hey, im inserted!' }); | ||
console.log(inserted); // { text: 'Hey hey, im inserted!' } | ||
``` | ||
static async findMany(query: Partial<IUser>): Promise<User[]> { | ||
const objects = await db.findMany(query); | ||
<br> | ||
return objects.map((obj) => { | ||
return new User(obj); | ||
}); | ||
} | ||
} | ||
### Querying | ||
Search query can be an object or a search function. If query is an object, then the search will be done by deeply comparing the fields values in the query with the fields values in the documents. | ||
In search queries you can use **Primitives** _(strings, numbers, booleans, nulls)_, **Arrays**, **Objects**, **RegExps** and **Functions**. | ||
```typescript | ||
await db.insertMany([ | ||
{ key: 1, value: 'one' }, | ||
{ key: 2, value: 'two' }, | ||
{ key: 3, value: 'three' }, | ||
]); | ||
// Simple query | ||
const found1 = await db.findOne({ key: 1 }); | ||
console.log(found1); // { key: 1, value: 'one' } | ||
// Advanced query with search function | ||
const found2 = await db.findOne((document: any) => document.key === 2); | ||
console.log(found2); // { key: 2, value: 'two' } | ||
``` | ||
### Using one of these classes | ||
When specifying **Arrays** or **Objects**, a deep comparison will be performed. | ||
```typescript | ||
await db.insertMany([ | ||
{ key: 1, values: [1, 2] }, | ||
{ key: 2, values: [1, 2, 3] }, | ||
{ key: 3, values: [1, 2, 3, 4] }, | ||
]); | ||
const found = await db.findOne({ values: [1, 2, 3] }); | ||
console.log(found); // { key: 2, values: [1, 2, 3] } | ||
``` | ||
<br> | ||
### Updating | ||
As with search queries, update queries can be either a function or an object. If this is a function, then the function receives the document to update as a parameter, and you must return updated document from the function. _(or return `null` or `{}` to delete it)_ | ||
By the way, you can pass a function as a parameter value in an object. This can be useful if you want to update a specific field in your document. Also, you can return `undefined`, to remove this field. | ||
```typescript | ||
await db.insertMany([ | ||
{ key: 1, value: 'one' }, | ||
{ key: 2, value: 'two' }, | ||
{ key: 3, value: 'three' }, | ||
]); | ||
// Simple update | ||
const updated1 = await db.updateOne({ key: 1 }, { key: 4, value: 'four' }); | ||
console.log(updated1); // { key: 4, value: 'four' } | ||
// Advanced update with update function | ||
const updated2 = await db.updateOne({ key: 2 }, (document: any) => { | ||
document.key = 5; | ||
document.value = 'five'; | ||
return document; | ||
}); | ||
console.log(updated2); // { key: 5, value: 'five' } | ||
// Advanced update with field update function | ||
const updated3 = await db.updateOne({ key: 3 }, { | ||
key: (value: any) => value === 6, | ||
value: (value: any) => value === 'six' | ||
}); | ||
console.log(updated3); // { key: 6, value: 'six' } | ||
``` | ||
<br> | ||
## 🔧 Methods | ||
### Documents | ||
```typescript | ||
// Create a new entity | ||
const newWeather = new Weather({temperature: 15, humidity: 32}); | ||
await newWeather.save(); | ||
db.documents; | ||
``` | ||
This property stores all your documents. It is better not to modify these property manually, as database methods do a bunch of checks for security and stability reasons. But, if you do this, be sure to call `await db.save()` method after your changes. | ||
// Update a existing entity | ||
newWeather.temperature = 16; | ||
newWeather.update(); | ||
<br> | ||
// Find and existing entity | ||
const oldWeather = await Weather.findOne({ id: "13d2e1c2-feda-498f-8540-dd92f1087161" }); | ||
### InsertOne | ||
```typescript | ||
await db.insertOne({ foo: 'bar' }); | ||
``` | ||
Inserts a document into the database. After insertion, it returns the inserted document. | ||
// Delete a existing entity | ||
await oldWeather.delete(); | ||
All fields with `undefined` values will be deleted. Empty documents will not be inserted. | ||
// Get an array of many entities | ||
const moreWeathers = await Weather.findMany({}); | ||
By default, the document will be inserted as the last entry in the database. To insert it somewhere else, specify the optional `index` parameter: | ||
for (const weather of moreWeathers) { | ||
console.log(weather.id); | ||
```typescript | ||
await db.insertOne({ foo: 'bar' }, 9); | ||
``` | ||
If the provided `index` is greater than the number of database entries, it will be inserted at the end. | ||
<br> | ||
### InsertMany | ||
```typescript | ||
await db.insertMany([{ foo: 'bar' }, { foo: 'baz' }]); | ||
``` | ||
Inserts multiple documents into the database. After insertion, it returns the array with inserted documents. | ||
This operation is **atomic**, so if something goes wrong, nothing will be inserted. | ||
<br> | ||
### FindOne | ||
```typescript | ||
await db.findOne({ foo: 'bar' }); | ||
``` | ||
Returns a document that matches the search query. Returns `null` if nothing found. | ||
<br> | ||
### FindMany | ||
```typescript | ||
await db.findMany({ foo: 'bar' }); | ||
``` | ||
Returns an array of documents matching the search query. | ||
<br> | ||
### UpdateOne | ||
```typescript | ||
await db.updateOne({ foo: 'bar' }, { foo: 'baz' }); | ||
``` | ||
Modifies an existing document that match search query. Returns the found document with applied modifications. If nothing is found, it will return `null`. | ||
The document will be deleted if all of its fields are `undefined`, or if you return `null` or `{}` using a update function. | ||
This operation is **atomic**, so if something goes wrong, nothing will be updated. | ||
<br> | ||
### UpdateMany | ||
```typescript | ||
await db.updateMany({ foo: 'bar' }, { foo: 'baz' }); | ||
``` | ||
Modifies all documents that match search query. Returns an array with updated documents. | ||
This operation is **atomic**, so if something goes wrong, nothing will be updated. | ||
<br> | ||
### DeleteOne | ||
```typescript | ||
await db.deleteOne({ foo: 'bar' }); | ||
``` | ||
Deletes first found document that matches the search query. After deletion, it will return deleted document. | ||
<br> | ||
### DeleteMany | ||
```typescript | ||
await db.deleteMany({ foo: 'bar' }); | ||
``` | ||
Deletes all documents that matches the search query. After deletion, it will return all deleted documents. | ||
This operation is **atomic**, so if something goes wrong, nothing will be deleted. | ||
<br> | ||
### Count | ||
```typescript | ||
await db.count({ foo: 'bar' }); | ||
``` | ||
Returns the number of documents found by the search query. If the query is not specified or empty, it will return total number of documents in the database. | ||
<br> | ||
### Drop | ||
```typescript | ||
await db.drop(); | ||
``` | ||
Removes all documents from the database. | ||
<br> | ||
### Load | ||
```typescript | ||
await db.load(); | ||
``` | ||
Loads, parses and validates documents from the specified database file. If the file is not specified, then nothing will be done. | ||
<br> | ||
### LoadSync | ||
```typescript | ||
db.loadSync(); | ||
``` | ||
Same as `db.load()` method, but synchronous. Will be called automatically if the `autoload` parameter is set to **true**. | ||
<br> | ||
### Save | ||
```typescript | ||
await db.save(); | ||
``` | ||
Saves documents from memory to a database file. If the `optimize` parameter is **false**, then the method execution will be completed when data writing is completely finished. Otherwise the data record will be added to the queue and executed later. | ||
<br> | ||
### Helpers | ||
This module contains helper functions that will make it easier to write and read search queries. | ||
```typescript | ||
// Importing database & helpers | ||
import { Database, and, includes, length, not, exists } from 'https://deno.land/x/aloedb@0.9.0/mod.ts'; | ||
const db = new Database(); | ||
await db.insertOne({ test: [1, 2, 3] }); | ||
// Helpers usage | ||
const found = await db.findOne({ | ||
test: and( | ||
length(3), | ||
includes(2) | ||
), | ||
other: not(exists()) | ||
}); | ||
console.log(found); // { test: [1, 2, 3] } | ||
``` | ||
#### List of all available helpers: | ||
* moreThan | ||
* moreThanOrEqual | ||
* lessThan | ||
* lessThanOrEqual | ||
* between | ||
* betweenOrEqual | ||
* exists | ||
* type | ||
* includes | ||
* length | ||
* someElementMatch | ||
* everyElementMatch | ||
* and | ||
* or | ||
* not | ||
<br> | ||
## 💡 Tips & Tricks | ||
### Multiple Collections | ||
By default, one database instance has only one collection. However, since the database instances are quite lightweight, you can initialize multiple instances for each collection. | ||
Keep in mind that you **cannot specify the same file for multiple instances!** | ||
```typescript | ||
import { Database } from 'https://deno.land/x/aloedb@0.9.0/mod.ts'; | ||
// Initialize database instances | ||
const users = new Database({ path: './users.json' }); | ||
const posts = new Database({ path: './posts.json' }); | ||
const comments = new Database({ path: './comments.json' }); | ||
// For convenience, you can collect all instances into one object | ||
const db = { users, posts, comments }; | ||
// Looks nice 😎 | ||
await db.users.insertOne({ username: 'john', password: 'qwerty123' }); | ||
``` | ||
<br> | ||
### Runtime Validation | ||
You cannot always be sure about the data that comes to your server. TypeScript highlights a lot of errors at compilation time, but it doesn't help at runtime. | ||
Luckily, you can use a library such as [SuperStruct](https://github.com/ianstormtaylor/superstruct), which allows you to check your documents structure: | ||
```typescript | ||
import { Database } from 'https://deno.land/x/aloedb@0.9.0/mod.ts'; | ||
import { assert, object, string, Infer } from 'https://cdn.skypack.dev/superstruct?dts'; | ||
// Specify structure | ||
const User = object({ | ||
username: string(), | ||
password: string() | ||
}); | ||
// Create validation function | ||
const UserValidator = (document: any) => assert(document, User); | ||
// Convert structure to TypeScript type | ||
type UserSchema = Infer<typeof User>; | ||
// Initialize | ||
const db = new Database<UserSchema>({ validator: UserValidator }); | ||
// Ok 👌 | ||
await db.insertOne({ username: 'bob', password: 'dylan' }); | ||
// StructError: At path: password -- Expected a string, but received: null | ||
await db.insertOne({ username: 'bob', password: null as any }); | ||
``` | ||
<br> | ||
### Manual Changes | ||
For performance reasons, a copy of the whole storage is kept in memory. Knowing this, you can modify the documents manually by modifying the `db.documents` parameter. | ||
Most of the time this is not necessary, as the built-in methods are sufficient, but if you want to have full control, you can do it! | ||
Keep in mind that after your changes, **you should always call the `await db.save()` method!** | ||
```typescript | ||
import { Database } from 'https://deno.land/x/aloedb@0.9.0/mod.ts'; | ||
// Initialize | ||
const db = new Database('./data.json'); | ||
try { | ||
// Your changes... | ||
db.documents.push({ foo: 'bar' }); | ||
} finally { | ||
await db.save(); // ALWAYS CALL SAVE! | ||
} | ||
``` | ||
## Support | ||
If you want to buy someone a coffee, try to get in contact with the <a href="https://github.com/Kirlovon/AloeDB">contributors from this package</a>, they did 99.9% of the work, I just made it run in node because I was frustrated with existing packages. | ||
Also, if you set the parameter **immutable** to `false` when initializing the database, you will get back references to in-memory documents instead of their copies. This means that you cannot change the returned documents without calling the `await db.save()` method. | ||
```typescript | ||
import { Database } from 'https://deno.land/x/aloedb@0.9.0/mod.ts'; | ||
// Initialization with immutability disabled | ||
const db = new Database({ path: './data.json', immutable: false }); | ||
// Initial data | ||
await db.insertOne({ field: 'The Days' }); | ||
// Finding and modifying document | ||
const found = await db.findOne({ field: 'The Days' }) as { field: string }; | ||
found.field = 'The Nights'; | ||
// Saving | ||
await db.save(); | ||
console.log(db.documents); // [{ field: 'The Nights' }] | ||
``` | ||
<br> | ||
## 🦄 Community Ports | ||
Surprisingly, this library was ported to other programming languages without my participation. **Much appreciation to this guys for their work!** ❤ | ||
🔵 **[AlgoeDB](https://github.com/billykirk01/AlgoeDB)** - database for Go, made by [billykirk01](https://github.com/billykirk01)! | ||
🟠 **[AlroeDB](https://github.com/billykirk01/AlroeDB)** - database for Rust, also made by [billykirk01](https://github.com/billykirk01)! | ||
🟢 **[AloeDB-Node](https://github.com/wouterdebruijn/AloeDB-Node)** - port to the Node.js, made by [Wouter de Bruijn](https://github.com/wouterdebruijn)! _(With awesome Active Records example)_ | ||
<br> | ||
## 📃 License | ||
MIT _(see [LICENSE](https://github.com/Kirlovon/AloeDB/blob/master/LICENSE) file)_ |
Sorry, the diff of this file is not supported yet
Empty package
Supply chain riskPackage does not contain any code. It may be removed, is name squatting, or the result of a faulty package publish.
Found 1 instance in 1 package
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 1 instance in 1 package
No bug tracker
MaintenancePackage does not have a linked bug tracker in package.json.
Found 1 instance in 1 package
No website
QualityPackage does not have a website.
Found 1 instance in 1 package
0
0
524
0
17330
2
3
0
2