├── .github └── workflows │ ├── codeql-analysis.yml │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── src └── index.ts ├── test ├── count.ts ├── functionFinds.ts ├── index.ts ├── indexes.ts ├── query.ts └── stress.ts └── tsconfig.json /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | name: "CodeQL" 7 | 8 | on: 9 | push: 10 | branches: [master] 11 | pull_request: 12 | # The branches below must be a subset of the branches above 13 | branches: [master] 14 | schedule: 15 | - cron: '0 3 * * 0' 16 | 17 | jobs: 18 | analyze: 19 | name: Analyze 20 | runs-on: ubuntu-latest 21 | 22 | strategy: 23 | fail-fast: false 24 | matrix: 25 | # Override automatic language detection by changing the below list 26 | # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] 27 | language: ['javascript'] 28 | # Learn more... 29 | # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection 30 | 31 | steps: 32 | - name: Checkout repository 33 | uses: actions/checkout@v2 34 | 35 | # Initializes the CodeQL tools for scanning. 36 | - name: Initialize CodeQL 37 | uses: github/codeql-action/init@v1 38 | with: 39 | languages: ${{ matrix.language }} 40 | # If you wish to specify custom queries, you can do so here or in a config file. 41 | # By default, queries listed here will override any specified in a config file. 42 | # Prefix the list here with "+" to use these queries and those in the config file. 43 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 44 | 45 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 46 | # If this step fails, then you should remove it and run the build manually (see below) 47 | - name: Autobuild 48 | uses: github/codeql-action/autobuild@v1 49 | 50 | # ℹ️ Command-line programs to run using the OS shell. 51 | # 📚 https://git.io/JvXDl 52 | 53 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 54 | # and modify them (or add more) to build your code if your project 55 | # uses a compiled language 56 | 57 | #- run: | 58 | # make bootstrap 59 | # make release 60 | 61 | - name: Perform CodeQL Analysis 62 | uses: github/codeql-action/analyze@v1 63 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Node.js Test Runner 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - master 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | strategy: 15 | matrix: 16 | node-version: [18.x, 20.x, 22.x] 17 | 18 | steps: 19 | - uses: actions/checkout@v2 20 | 21 | - name: Use Node.js ${{ matrix.node-version }} 22 | uses: actions/setup-node@v1 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | 26 | - run: npm install 27 | - run: npm run build --if-present 28 | - run: npm test 29 | env: 30 | CI: true 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | testData-* 4 | dist 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2020 Mark Wylde 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # doubledb 2 | 3 | An on disk database that indexes everything for fast querying. 4 | 5 | ## Installation 6 | ```bash 7 | npm install --save doubledb 8 | ``` 9 | 10 | ## Usage 11 | ```javascript 12 | import createDoubledb from 'doubledb'; 13 | const doubledb = await createDoubledb('./data'); 14 | 15 | // Insert a document 16 | await doubledb.insert({ 17 | id: undefined, // defaults to uuid, must be unique 18 | firstName: 'Joe', 19 | lastName: 'Bloggs', 20 | stats: { 21 | wins: 10, 22 | loses: 5 23 | }, 24 | skills: ['cooking', 'running'] 25 | }); 26 | 27 | // Read operations 28 | await doubledb.read(record.id); 29 | await doubledb.find('firstName', 'Joe'); 30 | await doubledb.find('stats.wins', 10); 31 | await doubledb.find('skills', 'cooking'); 32 | await doubledb.find('firstName', v => v.startsWith('J'), { skip: 20, gt: 'J', lt: 'K' }); 33 | await doubledb.filter('firstName', 'Joe'); 34 | await doubledb.filter('firstName', v => v.startsWith('J')); 35 | await doubledb.filter('firstName', v => v.startsWith('J'), { limit: 10, skip: 20, gt: 'J', lt: 'K' }); 36 | 37 | // Update operations 38 | await doubledb.upsert(record.id, { firstName: 'Joe', lastName: 'Bloggs' }); 39 | await doubledb.replace(record.id, { firstName: 'Joe', lastName: 'Bloggs' }); 40 | await doubledb.patch(record.id, { firstName: 'Bob' }); 41 | 42 | // Delete operation 43 | await doubledb.remove(record.id); 44 | 45 | // Batch operations 46 | await doubledb.batchInsert([ 47 | { firstName: 'Alice', lastName: 'Smith' }, 48 | { firstName: 'Bob', lastName: 'Johnson' }, 49 | { firstName: 'Charlie', lastName: 'Brown' } 50 | ]); 51 | 52 | // Advanced querying 53 | const users = await doubledb.query({ 54 | status: 'active', 55 | age: { $gte: 18 } 56 | }, { 57 | limit: 10, 58 | offset: 0, 59 | sort: { lastName: 1 } 60 | }); 61 | 62 | // Count documents 63 | const totalUsers = await doubledb.count({ status: 'active' }); 64 | ``` 65 | 66 | ## API Reference 67 | 68 | ### `.read(id)` 69 | Get a single record by its `.id` property. 70 | 71 | If a record is found, the whole record will be returned. 72 | If no record is found, `undefined` will be returned. 73 | 74 | ### `.find(field, value, { skip })` 75 | Quickly find a single record by any field (`use.dot.notation.for.nested.properties`) and its exact value. 76 | 77 | If multiple records exist, a `skip` option can be provided to ignore the first `number` of finds. 78 | 79 | If a record is found, the whole record will be returned. 80 | If no record is found, `undefined` will be returned. 81 | 82 | ### `.find(field, matcherFunction: (value: string) => boolean), { limit, skip, gt, lt, gte, lte })` 83 | Slow find a single record by any field and test against a `matcherFunction`. 84 | 85 | If multiple records exist: 86 | - a `skip` option can be provided to ignore the first `number` of finds. 87 | - a `limit` option can be provided to stop after `number` of finds. 88 | 89 | Find using a matcherFunction will work without a `gt` and `lt`, but the indexing will be pretty useless, as it will need to scan every single record. 90 | 91 | You should provide a `gt` and/or `lt` to let the indexer know where to begin/end. 92 | 93 | For example: 94 | ```javascript 95 | // Will scan every record (slow) 96 | doubledb.find('firstName', v => v.startsWith('Jo')) 97 | 98 | // Better - starts from 'Jo' 99 | doubledb.find('firstName', v => v.startsWith('Jo'), { gt: 'Jo' }) 100 | 101 | // Best - specify both start and end range 102 | doubledb.find('firstName', v => v.startsWith('Jo'), { gt: 'Jo', lt: 'K' }) 103 | 104 | // More examples 105 | doubledb.find('favouriteNumber', v => v > 5 && v < 10, { gt: 5, lt: 10 }) 106 | doubledb.find('firstName', v => ['Dave', 'Peter'].includes(v), { gt: 'Dave', lte: 'Peter' }) 107 | ``` 108 | 109 | ### `.filter(field, matcherFunction: (value: string) => boolean), { limit, skip, gt, lt, gte, lte })` 110 | This works the exact same as `.find` but will return an array of all matching records instead of just the first match. 111 | 112 | If records are found, an array will be returned containing every complete found record. 113 | If no records are found, an empty array will be returned. 114 | 115 | ### `.replace(key, object)` 116 | Completely replace a key with a new object, losing all previous fields in the record. 117 | 118 | ### `.patch(key, object)` 119 | Merge the new object with the existing record. 120 | 121 | Example: 122 | ```javascript 123 | // Existing record 124 | { 125 | "id": "1", 126 | "firstName": "Joe", 127 | "lastName": "Bloggs" 128 | } 129 | 130 | // After running: 131 | await doubledb.patch('1', { fullName: 'Joe Bloggs' }) 132 | 133 | // Result: 134 | { 135 | "id": "1", 136 | "firstName": "Joe", 137 | "lastName": "Bloggs", 138 | "fullName": "Joe Bloggs" 139 | } 140 | ``` 141 | 142 | ### `.remove(key)` 143 | Remove a record by its id. 144 | 145 | ### `.query(queryObject, options)` 146 | Query the database using a complex query object with support for multiple operators and conditions. 147 | 148 | **Example:** 149 | ```javascript 150 | const records = await doubledb.query({ 151 | location: 'London', 152 | age: { $gte: 18 }, 153 | $or: [ 154 | { status: 'active' }, 155 | { status: 'pending' } 156 | ] 157 | }, { 158 | limit: 10, 159 | offset: 0, 160 | sort: { lastName: 1 }, 161 | project: { firstName: 1, lastName: 1 } 162 | }); 163 | ``` 164 | 165 | #### Supported Operators: 166 | - `$eq`: Equal to a value 167 | - `$ne`: Not equal to a value 168 | - `$gt`: Greater than a value 169 | - `$gte`: Greater than or equal to a value 170 | - `$lt`: Less than a value 171 | - `$lte`: Less than or equal to a value 172 | - `$in`: Value is in the provided array 173 | - `$nin`: Value is not in the provided array 174 | - `$all`: Array contains all the provided values 175 | - `$exists`: Field exists or does not exist 176 | - `$not`: Negates the condition 177 | - `$sw`: String starts with value 178 | 179 | ### `.count(queryObject?)` 180 | Count the number of documents matching the given query criteria. If no query object is provided, returns the total number of documents in the database. 181 | 182 | **Example:** 183 | ```javascript 184 | // Count total documents 185 | const total = await doubledb.count(); 186 | 187 | // Count documents matching a simple field value 188 | const londonCount = await doubledb.count({ location: 'London' }); 189 | 190 | // Count using operators 191 | const activeUsersCount = await doubledb.count({ 192 | status: 'active', 193 | age: { $gte: 18 } 194 | }); 195 | 196 | // Count with $or conditions 197 | const count = await doubledb.count({ 198 | $or: [ 199 | { category: 'A' }, 200 | { category: 'B' } 201 | ] 202 | }); 203 | ``` 204 | 205 | The count method supports all the same operators as the query method. For optimal performance, it uses internal counters for simple queries, while complex queries may require scanning index entries. 206 | 207 | ### `.batchInsert(documents)` 208 | Insert multiple documents at once for better performance. 209 | 210 | **Example:** 211 | ```javascript 212 | await doubledb.batchInsert([ 213 | { firstName: 'Alice', lastName: 'Smith' }, 214 | { firstName: 'Bob', lastName: 'Johnson' }, 215 | { firstName: 'Charlie', lastName: 'Brown' } 216 | ]); 217 | ``` 218 | 219 | If the documents are successfully inserted, an array of the inserted documents will be returned. 220 | If the documents array is empty, an error will be thrown. 221 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "doubledb", 3 | "version": "3.4.3", 4 | "type": "module", 5 | "description": "An on disk database that indexes everything for fast querying.", 6 | "main": "dist/index.js", 7 | "types": "dist/index.d.ts", 8 | "scripts": { 9 | "build": "tsc", 10 | "postinstall": "tsc", 11 | "prepublish": "tsc", 12 | "coverage": "c8 -r html tsx --test test/*.ts && open coverage/index.html", 13 | "test": "c8 tsx --test test/*.ts", 14 | "test:only": "c8 tsx --test --test-only test/*.ts" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/markwylde/doubledb.git" 19 | }, 20 | "keywords": [ 21 | "database", 22 | "leveldb", 23 | "kv", 24 | "key value", 25 | "keystore", 26 | "lookup", 27 | "embedded" 28 | ], 29 | "author": { 30 | "name": "Mark Wylde", 31 | "email": "me@markwylde.com", 32 | "url": "https://github.com/markwylde" 33 | }, 34 | "license": "MIT", 35 | "bugs": { 36 | "url": "https://github.com/markwylde/doubledb/issues" 37 | }, 38 | "homepage": "https://github.com/markwylde/doubledb#readme", 39 | "devDependencies": { 40 | "@types/node": "^22.14.1", 41 | "@types/uuid": "^10.0.0", 42 | "c8": "^10.1.3", 43 | "semistandard": "^17.0.0", 44 | "tsx": "^4.19.3", 45 | "typescript": "^5.8.3" 46 | }, 47 | "dependencies": { 48 | "level": "^9.0.0", 49 | "uuid": "^11.1.0" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from 'fs'; 2 | import { Level } from 'level'; 3 | import { v4 as uuid } from 'uuid'; 4 | 5 | export const FirstUnicodeCharacter = '\u{0000}'; 6 | export const LastUnicodeCharacter = '\u{10FFFF}'; 7 | 8 | type Document = { 9 | id?: string; 10 | [key: string]: any; 11 | } 12 | 13 | type QueryOptions = { 14 | skip?: number; 15 | limit?: number; 16 | gt?: string; 17 | lt?: string; 18 | lte?: string; 19 | gte?: string; 20 | sort?: { [key: string]: 1 | -1 }; 21 | project?: { [key: string]: 1 }; 22 | } 23 | 24 | export type DoubleDb = { 25 | _level: Level; 26 | find: (key: string, valueOrFunction: any, options?: QueryOptions) => Promise; 27 | filter: (key: string, valueOrFunction: any, options?: QueryOptions) => Promise; 28 | insert: (document: Document) => Promise; 29 | replace: (id: string, newDocument: Document) => Promise; 30 | patch: (id: string, newDocument: Partial) => Promise; 31 | remove: (id: string) => Promise; 32 | read: (id: string) => Promise; 33 | query: (queryObject?: object, options?: { limit?: number; offset?: number; sort?: { [key: string]: 1 | -1 }; project?: { [key: string]: 1 } }) => Promise; 34 | count: (queryObject?: object) => Promise; 35 | close: () => Promise; 36 | batchInsert: (documents: Document[]) => Promise; 37 | upsert: (id: string, document: Document) => Promise; 38 | } 39 | 40 | function notFoundToUndefined(error: Error & { code?: string }): undefined { 41 | if (error.code === 'LEVEL_NOT_FOUND') { 42 | return undefined; 43 | } 44 | throw error; 45 | } 46 | 47 | const isObject = (thing: any): thing is object => thing instanceof Object && !Array.isArray(thing); 48 | 49 | async function createDoubleDb(dataDirectory: string): Promise { 50 | await fs.mkdir(dataDirectory, { recursive: true }); 51 | 52 | const db = new Level(dataDirectory); 53 | 54 | async function incrementCount(prefix: string, key: string, value: any): Promise { 55 | const countKey = `counts${prefix}.${key}=${value}`; 56 | 57 | // Use atomic get-and-update 58 | while (true) { 59 | try { 60 | let currentCount; 61 | try { 62 | currentCount = await db.get(countKey).then(Number); 63 | if (isNaN(currentCount)) { 64 | currentCount = 0; 65 | } 66 | } catch (error) { 67 | if (error.code === 'LEVEL_NOT_FOUND') { 68 | currentCount = 0; 69 | } else { 70 | throw error; 71 | } 72 | } 73 | 74 | const newCount = currentCount + 1; 75 | await db.put(countKey, newCount.toString()); 76 | break; 77 | } catch (error) { 78 | if (error.code === 'LEVEL_NOT_FOUND') { 79 | await db.put(countKey, '1'); 80 | break; 81 | } 82 | // If we get a conflict, retry 83 | if (error.code === 'LEVEL_LOCKED') { 84 | continue; 85 | } 86 | console.error('ERROR in incrementCount:', error); 87 | throw error; 88 | } 89 | } 90 | } 91 | 92 | async function decrementCount(prefix: string, key: string, value: any): Promise { 93 | const countKey = `counts${prefix}.${key}=${value}`; 94 | 95 | // Use atomic get-and-update similar to incrementCount 96 | while (true) { 97 | try { 98 | let currentCount; 99 | try { 100 | currentCount = await db.get(countKey).then(Number); 101 | if (isNaN(currentCount)) { 102 | currentCount = 0; 103 | } 104 | } catch (error) { 105 | if (error.code === 'LEVEL_NOT_FOUND') { 106 | currentCount = 0; 107 | } else { 108 | throw error; 109 | } 110 | } 111 | 112 | const newCount = currentCount - 1; 113 | if (newCount > 0) { 114 | await db.put(countKey, newCount.toString()); 115 | } else { 116 | await db.del(countKey).catch(() => {}); // Ignore if key doesn't exist 117 | } 118 | break; 119 | } catch (error) { 120 | if (error.code === 'LEVEL_NOT_FOUND') { 121 | // Key doesn't exist, nothing to decrement 122 | break; 123 | } 124 | // If we get a conflict, retry 125 | if (error.code === 'LEVEL_LOCKED') { 126 | continue; 127 | } 128 | console.error('ERROR in decrementCount:', error); 129 | throw error; 130 | } 131 | } 132 | } 133 | 134 | async function addToIndexes(id: string, object: object, prefix: string = ''): Promise { 135 | const promises: Promise[] = []; 136 | 137 | const addIndex = async (key: string, value: any): Promise => { 138 | const indexKey = 'indexes' + prefix + '.' + key + '=' + value + '|' + id; 139 | promises.push(db.put(indexKey, id)); 140 | promises.push(incrementCount(prefix, key, value)); 141 | }; 142 | 143 | for (const [key, value] of Object.entries(object)) { 144 | if (isObject(value)) { 145 | // For nested objects, both add indexes for the nested fields 146 | // and create an index for the full path 147 | promises.push(addToIndexes(id, value, prefix + '.' + key)); 148 | for (const [nestedKey, nestedValue] of Object.entries(value)) { 149 | if (!isObject(nestedValue)) { 150 | const fullKey = `${key}.${nestedKey}`; 151 | promises.push(addIndex(fullKey, nestedValue)); 152 | } 153 | } 154 | } else if (Array.isArray(value)) { 155 | value.forEach(item => promises.push(addIndex(key, item))); 156 | } else { 157 | promises.push(addIndex(key, value)); 158 | } 159 | } 160 | 161 | await Promise.all(promises); 162 | } 163 | 164 | async function removeIndexesForDocument(id: string, document: string | object, prefix: string = ''): Promise { 165 | const parsedDocument = typeof document === 'string' ? JSON.parse(document) : document; 166 | const promises: Promise[] = []; 167 | 168 | const removeIndex = async (key: string, value: any): Promise => { 169 | const indexKey = 'indexes' + prefix + '.' + key + '=' + value + '|' + id; 170 | promises.push(db.del(indexKey).catch(() => {})); 171 | promises.push(decrementCount(prefix, key, value)); 172 | }; 173 | 174 | for (const [key, value] of Object.entries(parsedDocument)) { 175 | if (isObject(value)) { 176 | // Remove indexes for both nested fields and full paths 177 | promises.push(removeIndexesForDocument(id, value, prefix + '.' + key)); 178 | for (const [nestedKey, nestedValue] of Object.entries(value)) { 179 | if (!isObject(nestedValue)) { 180 | const fullKey = `${key}.${nestedKey}`; 181 | promises.push(removeIndex(fullKey, nestedValue)); 182 | } 183 | } 184 | } else if (Array.isArray(value)) { 185 | value.forEach(item => promises.push(removeIndex(key, item))); 186 | } else { 187 | promises.push(removeIndex(key, value)); 188 | } 189 | } 190 | 191 | await Promise.all(promises); 192 | } 193 | 194 | async function insert(document: Document): Promise { 195 | if (!document) { 196 | throw new Error('doubledb.insert: no document was supplied to insert function'); 197 | } 198 | 199 | if (document.id) { 200 | const existingDocument = await db.get(document.id) 201 | .catch(notFoundToUndefined); 202 | 203 | if (existingDocument) { 204 | throw new Error(`doubledb.insert: document with id ${document.id} already exists`); 205 | } 206 | } 207 | 208 | const id = document.id || uuid(); 209 | const puttableRecord = { 210 | id, 211 | ...document 212 | }; 213 | 214 | await db.put(id, JSON.stringify(puttableRecord)); 215 | await addToIndexes(id, puttableRecord); 216 | await incrementCount('', '__total__', 'documents'); 217 | 218 | return puttableRecord; 219 | } 220 | 221 | async function replace(id: string, newDocument: Document): Promise { 222 | if (!id) { 223 | throw new Error('doubledb.replace: no id was supplied to replace function'); 224 | } 225 | 226 | if (!newDocument) { 227 | throw new Error('doubledb.replace: no newDocument was supplied to replace function'); 228 | } 229 | 230 | if (newDocument.id && id !== newDocument.id) { 231 | throw new Error(`doubledb.replace: the id (${id}) and newDocument.id (${newDocument.id}) must be the same, or not defined`); 232 | } 233 | 234 | const existingDocument = await db.get(id) 235 | .catch(notFoundToUndefined); 236 | 237 | if (!existingDocument) { 238 | throw new Error(`doubledb.replace: document with id ${id} does not exist`); 239 | } 240 | 241 | await removeIndexesForDocument(id, existingDocument); 242 | 243 | const puttableRecord = { 244 | ...newDocument, 245 | id 246 | }; 247 | await db.put(id, JSON.stringify(puttableRecord)); 248 | await addToIndexes(id, puttableRecord); 249 | 250 | return puttableRecord; 251 | } 252 | 253 | async function patch(id: string, newDocument: Partial): Promise { 254 | if (!id) { 255 | throw new Error('doubledb.patch: no id was supplied to patch function'); 256 | } 257 | 258 | if (!newDocument) { 259 | throw new Error('doubledb.patch: no newDocument was supplied to patch function'); 260 | } 261 | 262 | if (newDocument.id && id !== newDocument.id) { 263 | throw new Error(`doubledb.patch: the id (${id}) and newDocument.id (${newDocument.id}) must be the same, or not defined`); 264 | } 265 | 266 | const existingDocument = await db.get(id) 267 | .catch(notFoundToUndefined); 268 | 269 | if (!existingDocument) { 270 | throw new Error(`doubledb.patch: document with id ${id} does not exist`); 271 | } 272 | 273 | await removeIndexesForDocument(id, existingDocument); 274 | 275 | const puttableRecord = { 276 | ...JSON.parse(existingDocument), 277 | ...newDocument, 278 | id 279 | }; 280 | await db.put(id, JSON.stringify(puttableRecord)); 281 | await addToIndexes(id, puttableRecord); 282 | 283 | return puttableRecord; 284 | } 285 | 286 | async function read(id: string): Promise { 287 | const document = await db.get(id) 288 | .catch(notFoundToUndefined); 289 | 290 | if (!document) { 291 | return undefined; 292 | } 293 | 294 | return JSON.parse(document); 295 | } 296 | 297 | async function findOrFilter(type: 'find' | 'filter', key: string, value: any, options?: QueryOptions): Promise { 298 | options = { 299 | skip: 0, 300 | limit: type === 'find' ? 1 : Infinity, 301 | ...options 302 | }; 303 | const promises: Promise[] = []; 304 | let skipIndex = 0; 305 | for await (const ckey of db.keys({ 306 | gt: `indexes.${key}=${value}|`, 307 | lte: `indexes.${key}=${value}|${LastUnicodeCharacter}` 308 | })) { 309 | const [, lvalueAndKey] = ckey.split('='); 310 | const lvalue = lvalueAndKey.split('|')[0]; 311 | 312 | if (lvalue == value) { 313 | if (skipIndex < options.skip!) { 314 | skipIndex = skipIndex + 1; 315 | continue; 316 | } 317 | 318 | const id = await db.get(ckey); 319 | promises.push(read(id)); 320 | if (type === 'find') { 321 | break; 322 | } 323 | 324 | if (promises.length >= options.limit!) { 325 | break; 326 | } 327 | } 328 | } 329 | 330 | const results = await Promise.all(promises); 331 | if (type === 'filter') { 332 | return results.filter((doc): doc is Document => doc !== undefined); 333 | } else { 334 | return results[0]; 335 | } 336 | } 337 | 338 | async function findOrFilterByFunction(type: 'find' | 'filter', key: string, fn: (value: any) => boolean, options?: QueryOptions): Promise { 339 | options = { 340 | skip: 0, 341 | limit: type === 'find' ? 1 : Infinity, 342 | gt: options?.gt, 343 | lt: options?.lt, 344 | lte: options?.lte, 345 | gte: options?.gte, 346 | ...options 347 | }; 348 | const promises: Promise[] = []; 349 | let skipIndex = 0; 350 | 351 | const query: { [key: string]: string } = {}; 352 | 353 | if (options.gte) { 354 | query.gte = `indexes.${key}=${options.gte}`; 355 | } 356 | if (options.gt) { 357 | query.gt = `indexes.${key}=${options.gt}|${LastUnicodeCharacter}`; 358 | } 359 | if (options.lte) { 360 | query.lte = `indexes.${key}=${options.lte}|${LastUnicodeCharacter}`; 361 | } 362 | if (options.lt) { 363 | query.lt = `indexes.${key}=${options.lt}|${FirstUnicodeCharacter}`; 364 | } 365 | if (!options.lt && !options.lte) { 366 | query.lte = `indexes.${key}=${LastUnicodeCharacter}`; 367 | } 368 | if (!options.gt && !options.gte) { 369 | query.gte = `indexes.${key}=`; 370 | } 371 | 372 | for await (const ckey of db.keys(query)) { 373 | const [, lvalueAndKey] = ckey.split('='); 374 | const lvalue = lvalueAndKey.split('|')[0]; 375 | 376 | if (fn(lvalue)) { 377 | if (skipIndex < options.skip!) { 378 | skipIndex = skipIndex + 1; 379 | continue; 380 | } 381 | 382 | const id = await db.get(ckey); 383 | promises.push(read(id)); 384 | if (type === 'find') { 385 | break; 386 | } 387 | 388 | if (promises.length >= options.limit!) { 389 | break; 390 | } 391 | } 392 | } 393 | 394 | const results = await Promise.all(promises); 395 | if (type === 'filter') { 396 | return results.filter((doc): doc is Document => doc !== undefined); 397 | } else { 398 | return results[0]; 399 | } 400 | } 401 | 402 | async function find(key: string, valueOrFunction: any, options?: QueryOptions): Promise { 403 | return valueOrFunction instanceof Function 404 | ? findOrFilterByFunction('find', key, valueOrFunction, options) as Promise 405 | : findOrFilter('find', key, valueOrFunction, options) as Promise; 406 | } 407 | 408 | async function filter(key: string, valueOrFunction: any, options?: QueryOptions): Promise { 409 | return valueOrFunction instanceof Function 410 | ? findOrFilterByFunction('filter', key, valueOrFunction, options) as Promise 411 | : findOrFilter('filter', key, valueOrFunction, options) as Promise; 412 | } 413 | 414 | async function remove(id: string): Promise { 415 | if (!id) { 416 | throw new Error('doubledb.remove: no id was supplied to replace function'); 417 | } 418 | 419 | const existingDocument = await db.get(id) 420 | .catch(notFoundToUndefined); 421 | 422 | if (!existingDocument) { 423 | throw new Error(`doubledb.remove: document with id ${id} does not exist`); 424 | } 425 | 426 | await removeIndexesForDocument(id, existingDocument); 427 | await decrementCount('', '__total__', 'documents'); 428 | return db.del(id); 429 | } 430 | 431 | async function getCountForKeyValue(prefix: string, key: string, value: any): Promise { 432 | const countKey = `counts${prefix}.${key}=${value}`; 433 | try { 434 | const rawCount = await db.get(countKey); 435 | const count = Number(rawCount); 436 | if (isNaN(count)) { 437 | return 0; 438 | } 439 | return count; 440 | } catch (error) { 441 | if (error.code === 'LEVEL_NOT_FOUND') { 442 | return 0; 443 | } 444 | console.error('ERROR in getCountForKeyValue:', error); 445 | return 0; 446 | } 447 | } 448 | 449 | async function getTotalCount(): Promise { 450 | try { 451 | const count = await getCountForKeyValue('', '__total__', 'documents'); 452 | if (isNaN(count)) { 453 | console.log('WARNING: getTotalCount returned NaN, defaulting to 0'); 454 | return 0; 455 | } 456 | return count; 457 | } catch (error) { 458 | console.log('ERROR in getTotalCount:', error); 459 | return 0; 460 | } 461 | } 462 | 463 | async function getCountForOperators(prefix: string, key: string, operators: object): Promise { 464 | let totalCount = 0; 465 | let isFirstOperator = true; 466 | 467 | for (const [op, value] of Object.entries(operators)) { 468 | let count; 469 | switch (op) { 470 | case '$eq': 471 | count = await getCountForKeyValue(prefix, key, value); 472 | break; 473 | case '$ne': 474 | count = await getTotalCount(); 475 | const excludeCount = await getCountForKeyValue(prefix, key, value); 476 | count = count - excludeCount; 477 | break; 478 | case '$in': 479 | count = 0; 480 | for (const val of value as any[]) { 481 | count += await getCountForKeyValue(prefix, key, val); 482 | } 483 | break; 484 | case '$nin': 485 | count = await getTotalCount(); 486 | for (const val of value as any[]) { 487 | count -= await getCountForKeyValue(prefix, key, val); 488 | } 489 | break; 490 | case '$exists': 491 | if (value) { 492 | count = await sumCountsForPrefix(`counts${prefix}.${key}=`); 493 | } else { 494 | count = await getTotalCount() - await sumCountsForPrefix(`counts${prefix}.${key}=`); 495 | } 496 | break; 497 | default: 498 | // Fall back to ID collection for unsupported operators 499 | const ids = await handleOperators(key, { [op]: value }); 500 | count = ids.size; 501 | break; 502 | } 503 | totalCount = isFirstOperator ? count : Math.min(totalCount, count); 504 | isFirstOperator = false; 505 | } 506 | 507 | return totalCount; 508 | } 509 | 510 | async function sumCountsForPrefix(prefix: string): Promise { 511 | let sum = 0; 512 | for await (const [, value] of db.iterator({ 513 | gte: prefix, 514 | lt: prefix + LastUnicodeCharacter 515 | })) { 516 | sum += Number(value); 517 | } 518 | return sum; 519 | } 520 | 521 | async function count(queryObject?: object): Promise { 522 | if (!queryObject || Object.keys(queryObject).length === 0) { 523 | return getTotalCount(); 524 | } 525 | 526 | let totalCount = 0; 527 | let isFirstCondition = true; 528 | 529 | for (const [key, value] of Object.entries(queryObject)) { 530 | if (key === '$or') { 531 | const uniqueIds = new Set(); 532 | for (const subQuery of value as object[]) { 533 | const subQueryIds = await getAllIdsForQuery(subQuery); 534 | subQueryIds.forEach(id => uniqueIds.add(id)); 535 | } 536 | const orCount = uniqueIds.size; 537 | totalCount = isFirstCondition ? orCount : Math.min(totalCount, orCount); 538 | } else if (key.startsWith('$')) { 539 | throw new Error(`Unsupported top-level operator: ${key}`); 540 | } else { 541 | let fieldCount; 542 | if (isObject(value) && Object.keys(value).some(k => k.startsWith('$'))) { 543 | fieldCount = await getCountForOperators('', key, value as object); 544 | } else { 545 | fieldCount = await getCountForKeyValue('', key, value); 546 | } 547 | totalCount = isFirstCondition ? fieldCount : Math.min(totalCount, fieldCount); 548 | } 549 | isFirstCondition = false; 550 | } 551 | 552 | return totalCount; 553 | } 554 | 555 | async function query(queryObject?: object, options?: { limit?: number; offset?: number; sort?: { [key: string]: 1 | -1 }; project?: { [key: string]: 1 } }): Promise { 556 | if (!queryObject || Object.keys(queryObject).length === 0) { 557 | queryObject = { id: { $exists: true } }; 558 | } 559 | 560 | let resultIds = new Set(); 561 | let isFirstCondition = true; 562 | 563 | for (const [key, value] of Object.entries(queryObject)) { 564 | if (key === '$or') { 565 | const orResults = await Promise.all((value as object[]).map(subQuery => query(subQuery))); 566 | const orIds = new Set(orResults.flat().map(doc => doc.id)); 567 | resultIds = isFirstCondition ? orIds : new Set([...resultIds].filter(id => orIds.has(id))); 568 | } else if (key.startsWith('$')) { 569 | throw new Error(`Unsupported top-level operator: ${key}`); 570 | } else { 571 | let ids; 572 | if (isObject(value) && Object.keys(value).some(k => k.startsWith('$'))) { 573 | ids = await handleOperators(key, value as object); 574 | } else { 575 | ids = await getIdsForKeyValue(key, value); 576 | } 577 | resultIds = isFirstCondition ? ids : new Set([...resultIds].filter(id => ids.has(id))); 578 | } 579 | isFirstCondition = false; 580 | } 581 | 582 | let results = await Promise.all([...resultIds].map(id => read(id))); 583 | const offset = options?.offset ?? 0; 584 | const limit = options?.limit ?? results.length; 585 | 586 | if (options?.sort) { 587 | const sortFields = Object.entries(options.sort); 588 | results.sort((a, b) => { 589 | for (const [field, direction] of sortFields) { 590 | const aValue = field.split('.').reduce((obj, key) => obj[key], a); 591 | const bValue = field.split('.').reduce((obj, key) => obj[key], b); 592 | 593 | if (aValue < bValue) 594 | return direction === 1 ? -1 : 1; 595 | if (aValue > bValue) 596 | return direction === 1 ? 1 : -1; 597 | } 598 | return 0; 599 | }); 600 | } 601 | 602 | if (options?.project) { 603 | results = results.map(doc => { 604 | const projected: Document = {}; 605 | for (const field of Object.keys(options.project)) { 606 | if (field in doc) { 607 | projected[field] = doc[field]; 608 | } 609 | } 610 | return projected; 611 | }); 612 | } 613 | 614 | return results.filter((doc): doc is Document => doc !== undefined).slice(offset, offset + limit); 615 | } 616 | 617 | async function batchInsert(documents: Document[]): Promise { 618 | if (!Array.isArray(documents) || documents.length === 0) { 619 | throw new Error('doubledb.batchInsert: documents must be a non-empty array'); 620 | } 621 | 622 | const idsToRollback = new Set(); 623 | try { 624 | const ops: { type: 'put'; key: string; value: string }[] = []; 625 | const processedDocs: Document[] = []; 626 | 627 | for (const doc of documents) { 628 | const id = doc.id || uuid(); 629 | const puttableRecord = { id, ...doc }; 630 | ops.push({ type: 'put', key: id, value: JSON.stringify(puttableRecord) }); 631 | processedDocs.push(puttableRecord); 632 | idsToRollback.add(id); 633 | } 634 | 635 | // First, batch insert all documents 636 | await db.batch(ops); 637 | 638 | // Process indexes and counts sequentially to avoid race conditions 639 | for (const doc of processedDocs) { 640 | await addToIndexes(doc.id!, doc); 641 | } 642 | 643 | // Update the total count once with the total number of documents 644 | const currentTotal = await getTotalCount(); 645 | await db.put('counts.__total__=documents', (currentTotal + documents.length).toString()); 646 | 647 | return processedDocs; 648 | } catch (error) { 649 | // Attempt to rollback 650 | try { 651 | for (const id of idsToRollback) { 652 | const doc = await read(id).catch((): undefined => undefined); 653 | if (doc) { 654 | await removeIndexesForDocument(id, doc); 655 | await db.del(id).catch((): void => {}); 656 | } 657 | } 658 | } catch (rollbackError) { 659 | console.error('Error during rollback:', rollbackError); 660 | } 661 | throw error; 662 | } 663 | } 664 | 665 | async function upsert(id: string, document: Document): Promise { 666 | if (!id) { 667 | throw new Error('doubledb.upsert: no id was supplied to upsert function'); 668 | } 669 | 670 | if (!document) { 671 | throw new Error('doubledb.upsert: no document was supplied to upsert function'); 672 | } 673 | 674 | const existingDocument = await db.get(id).catch(notFoundToUndefined); 675 | 676 | if (existingDocument) { 677 | await removeIndexesForDocument(id, existingDocument); 678 | } else { 679 | await incrementCount('', '__total__', 'documents'); 680 | } 681 | 682 | const puttableRecord = { 683 | ...JSON.parse(existingDocument || '{}'), 684 | ...document, 685 | id 686 | }; 687 | 688 | await db.put(id, JSON.stringify(puttableRecord)); 689 | await addToIndexes(id, puttableRecord); 690 | 691 | return puttableRecord; 692 | } 693 | 694 | async function handleOperators(key: string, operators: object): Promise> { 695 | let resultIds = new Set(); 696 | let isFirstOperator = true; 697 | 698 | // Get all IDs that have this field 699 | const allIdsWithField = new Set(); 700 | for await (const ckey of db.keys({ 701 | gte: `indexes.${key}=`, 702 | lte: `indexes.${key}=${LastUnicodeCharacter}` 703 | })) { 704 | const id = await db.get(ckey); 705 | allIdsWithField.add(id); 706 | } 707 | 708 | for (const [op, value] of Object.entries(operators)) { 709 | let ids: Set; 710 | try { 711 | switch (op) { 712 | case '$eq': 713 | ids = await getIdsForKeyValue(key, value); 714 | break; 715 | 716 | case '$ne': 717 | const matchingIdsNe = await getIdsForKeyValue(key, value); 718 | ids = new Set([...allIdsWithField].filter(id => !matchingIdsNe.has(id))); 719 | break; 720 | 721 | case '$gt': 722 | case '$gte': 723 | case '$lt': 724 | case '$lte': 725 | if (value === null || value === undefined) { 726 | throw new Error(`${op} operator requires a non-null value`); 727 | } 728 | const compareValue = value instanceof Date ? value.toISOString() : value; 729 | ids = await getIdsForKeyValueRange(key, op, compareValue); 730 | break; 731 | 732 | case '$in': 733 | if (!Array.isArray(value)) { 734 | throw new Error('$in operator requires an array'); 735 | } 736 | if (value.length === 0) { 737 | ids = new Set(); 738 | } else { 739 | ids = await getIdsForKeyValueIn(key, value); 740 | } 741 | break; 742 | 743 | case '$nin': 744 | if (!Array.isArray(value)) { 745 | throw new Error('$nin operator requires an array'); 746 | } 747 | if (value.length === 0) { 748 | ids = allIdsWithField; 749 | } else { 750 | const matchingIds = await getIdsForKeyValueIn(key, value); 751 | ids = new Set([...allIdsWithField].filter(id => !matchingIds.has(id))); 752 | } 753 | break; 754 | 755 | case '$exists': 756 | if (typeof value !== 'boolean') { 757 | throw new Error('$exists operator requires a boolean value'); 758 | } 759 | ids = value ? allIdsWithField : new Set([...await getAllIds()].filter(id => !allIdsWithField.has(id))); 760 | break; 761 | 762 | case '$all': 763 | if (!Array.isArray(value)) { 764 | throw new Error('$all operator requires an array'); 765 | } 766 | if (value.length === 0) { 767 | ids = allIdsWithField; 768 | } else { 769 | ids = await getIdsForKeyValueAll(key, value); 770 | } 771 | break; 772 | 773 | case '$not': 774 | if (value === null || value === undefined) { 775 | throw new Error('$not operator requires a non-null value'); 776 | } 777 | if (isObject(value)) { 778 | const matchingIds = await handleOperators(key, value as object); 779 | ids = new Set([...allIdsWithField].filter(id => !matchingIds.has(id))); 780 | } else { 781 | const matchingIds = await getIdsForKeyValue(key, value); 782 | ids = new Set([...allIdsWithField].filter(id => !matchingIds.has(id))); 783 | } 784 | break; 785 | 786 | case '$sw': 787 | if (typeof value !== 'string') { 788 | throw new Error('$sw operator requires a string value'); 789 | } 790 | ids = await getIdsForKeyValueStartsWith(key, value); 791 | break; 792 | 793 | default: 794 | throw new Error(`Unsupported operator: ${op}`); 795 | } 796 | } catch (error) { 797 | if (error instanceof Error) { 798 | throw new Error(`Error processing ${op} operator for key ${key}: ${error.message}`); 799 | } 800 | throw error; 801 | } 802 | 803 | resultIds = isFirstOperator ? ids : new Set([...resultIds].filter(id => ids.has(id))); 804 | isFirstOperator = false; 805 | } 806 | 807 | return resultIds; 808 | } 809 | 810 | async function getIdsForKeyValue(key: string, value: any): Promise> { 811 | const ids = new Set(); 812 | for await (const ckey of db.keys({ 813 | gte: `indexes.${key}=${value}|`, 814 | lte: `indexes.${key}=${value}|${LastUnicodeCharacter}` 815 | })) { 816 | const id = await db.get(ckey); 817 | ids.add(id); 818 | } 819 | return ids; 820 | } 821 | 822 | async function getIdsForKeyValueStartsWith(key: string, prefix: string): Promise> { 823 | const ids = new Set(); 824 | for await (const ckey of db.keys({ 825 | gte: `indexes.${key}=${prefix}`, 826 | lt: `indexes.${key}=${prefix}${LastUnicodeCharacter}` 827 | })) { 828 | const id = await db.get(ckey); 829 | ids.add(id); 830 | } 831 | return ids; 832 | } 833 | 834 | async function getIdsForKeyValueNot(key: string, value: any): Promise> { 835 | const allIds = await getAllIds(); 836 | const idsToExclude = await getIdsForKeyValue(key, value); 837 | return new Set([...allIds].filter(id => !idsToExclude.has(id))); 838 | } 839 | 840 | async function getIdsForKeyValueRange(key: string, op: string, value: number): Promise> { 841 | const ids = new Set(); 842 | const query = { 843 | gte: `indexes.${key}=`, 844 | lte: `indexes.${key}=${LastUnicodeCharacter}` 845 | }; 846 | 847 | for await (const ckey of db.keys(query)) { 848 | const [, lvalueAndKey] = ckey.split('='); 849 | const lvalue = lvalueAndKey.split('|')[0]; 850 | const numericLvalue = Number(lvalue); 851 | 852 | if (!isNaN(numericLvalue)) { 853 | if ((op === '$gt' && numericLvalue > value) || 854 | (op === '$gte' && numericLvalue >= value) || 855 | (op === '$lt' && numericLvalue < value) || 856 | (op === '$lte' && numericLvalue <= value)) { 857 | const id = await db.get(ckey); 858 | ids.add(id); 859 | } 860 | } 861 | } 862 | return ids; 863 | } 864 | 865 | async function getIdsForKeyValueIn(key: string, values: any[]): Promise> { 866 | const ids = new Set(); 867 | for (const value of values) { 868 | const valueIds = await getIdsForKeyValue(key, value); 869 | valueIds.forEach(id => ids.add(id)); 870 | } 871 | return ids; 872 | } 873 | 874 | async function getIdsForKeyValueAll(key: string, values: any[]): Promise> { 875 | const ids = new Set(); 876 | 877 | for await (const ckey of db.keys({ 878 | gte: `indexes.${key}=`, 879 | lte: `indexes.${key}=${LastUnicodeCharacter}` 880 | })) { 881 | const [, lvalueAndKey] = ckey.split('='); 882 | const lvalue = lvalueAndKey.split('|')[0]; 883 | const id = await db.get(ckey); 884 | 885 | if (!ids.has(id)) { 886 | const document = await read(id); 887 | const documentValues = document?.[key]; 888 | if (Array.isArray(documentValues) && values.every(value => documentValues.includes(value))) { 889 | ids.add(id); 890 | } 891 | } 892 | } 893 | 894 | return ids; 895 | } 896 | 897 | async function getIdsForKeyValueNotIn(key: string, values: any[]): Promise> { 898 | const allIds = await getAllIds(); 899 | const idsToExclude = await getIdsForKeyValueIn(key, values); 900 | return new Set([...allIds].filter(id => !idsToExclude.has(id))); 901 | } 902 | 903 | async function getIdsForKeyExists(key: string, shouldExist: boolean): Promise> { 904 | const ids = new Set(); 905 | const query = { 906 | gte: `indexes.${key}=`, 907 | lt: `indexes.${key}=${LastUnicodeCharacter}` 908 | }; 909 | for await (const ckey of db.keys(query)) { 910 | const id = await db.get(ckey); 911 | ids.add(id); 912 | } 913 | if (!shouldExist) { 914 | const allIds = await getAllIds(); 915 | return new Set([...allIds].filter(id => !ids.has(id))); 916 | } 917 | return ids; 918 | } 919 | 920 | async function getAllIds(): Promise> { 921 | const ids = new Set(); 922 | for await (const key of db.keys({ 923 | // Only get keys that don't start with 'indexes' or 'counts' 924 | gt: '\x00', 925 | lt: 'counts' 926 | })) { 927 | // Additional check to ensure we only get document IDs 928 | if (!key.startsWith('indexes') && !key.startsWith('counts')) { 929 | ids.add(key); 930 | } 931 | } 932 | return ids; 933 | } 934 | 935 | // Helper function for count to get all matching IDs for a query 936 | async function getAllIdsForQuery(queryObject: object): Promise> { 937 | let resultIds = new Set(); 938 | let isFirstCondition = true; 939 | 940 | for (const [key, value] of Object.entries(queryObject)) { 941 | if (key === '$or') { 942 | const orResults = new Set(); 943 | for (const subQuery of value as object[]) { 944 | const subQueryIds = await getAllIdsForQuery(subQuery); 945 | subQueryIds.forEach(id => orResults.add(id)); 946 | } 947 | resultIds = isFirstCondition ? orResults : new Set([...resultIds].filter(id => orResults.has(id))); 948 | } else if (key.startsWith('$')) { 949 | throw new Error(`Unsupported top-level operator: ${key}`); 950 | } else { 951 | let ids; 952 | if (isObject(value) && Object.keys(value).some(k => k.startsWith('$'))) { 953 | ids = await handleOperators(key, value as object); 954 | } else { 955 | ids = await getIdsForKeyValue(key, value); 956 | } 957 | resultIds = isFirstCondition ? ids : new Set([...resultIds].filter(id => ids.has(id))); 958 | } 959 | isFirstCondition = false; 960 | } 961 | 962 | return resultIds; 963 | } 964 | 965 | async function getAllIdsExcept(excludeIds: Set): Promise> { 966 | const allIds = await getAllIds(); 967 | return new Set([...allIds].filter(id => !excludeIds.has(id))); 968 | } 969 | 970 | return { 971 | _level: db, 972 | find, 973 | filter, 974 | insert, 975 | replace, 976 | patch, 977 | remove, 978 | read, 979 | query, 980 | count, 981 | close: db.close.bind(db), 982 | batchInsert, 983 | upsert 984 | }; 985 | } 986 | 987 | export default createDoubleDb; 988 | -------------------------------------------------------------------------------- /test/count.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from 'fs'; 2 | import test from 'node:test'; 3 | import { strict as assert } from 'node:assert'; 4 | import createDoubleDb from '../src/index'; 5 | 6 | const testDir = './testData-' + Math.random(); 7 | 8 | test.after(async () => { 9 | await fs.rm(testDir, { recursive: true, force: true }); 10 | }); 11 | 12 | async function setupTestDb() { 13 | await fs.rm(testDir, { recursive: true }).catch(() => {}); 14 | const db = await createDoubleDb(testDir); 15 | return db; 16 | } 17 | 18 | test('count - basic functionality', async () => { 19 | const db = await setupTestDb(); 20 | 21 | // Empty database should return 0 22 | assert.strictEqual(await db.count(), 0); 23 | 24 | // Add some documents 25 | await db.insert({ value: 'alpha' }); 26 | await db.insert({ value: 'beta' }); 27 | await db.insert({ value: 'gamma' }); 28 | 29 | // Total count should be 3 30 | assert.strictEqual(await db.count(), 3); 31 | 32 | await db.close(); 33 | }); 34 | 35 | test('count - with simple query', async () => { 36 | const db = await setupTestDb(); 37 | 38 | await db.insert({ value: 'alpha', type: 'letter' }); 39 | await db.insert({ value: 'beta', type: 'letter' }); 40 | await db.insert({ value: '1', type: 'number' }); 41 | 42 | assert.strictEqual(await db.count({ type: 'letter' }), 2); 43 | assert.strictEqual(await db.count({ type: 'number' }), 1); 44 | 45 | await db.close(); 46 | }); 47 | 48 | test('count - with comparison operators', async () => { 49 | const db = await setupTestDb(); 50 | 51 | await db.insert({ value: 5 }); 52 | await db.insert({ value: 10 }); 53 | await db.insert({ value: 15 }); 54 | await db.insert({ value: 20 }); 55 | 56 | assert.strictEqual(await db.count({ value: { $eq: 10 } }), 1); 57 | assert.strictEqual(await db.count({ value: { $ne: 10 } }), 3); 58 | assert.strictEqual(await db.count({ value: { $gt: 10 } }), 2); 59 | assert.strictEqual(await db.count({ value: { $gte: 10 } }), 3); 60 | assert.strictEqual(await db.count({ value: { $lt: 10 } }), 1); 61 | assert.strictEqual(await db.count({ value: { $lte: 10 } }), 2); 62 | 63 | await db.close(); 64 | }); 65 | 66 | test('count - with array operators', async () => { 67 | const db = await setupTestDb(); 68 | 69 | await db.insert({ tags: ['a', 'b', 'c'] }); 70 | await db.insert({ tags: ['a', 'b'] }); 71 | await db.insert({ tags: ['a'] }); 72 | await db.insert({ value: 'no-tags' }); 73 | 74 | assert.strictEqual(await db.count({ tags: { $in: ['a'] } }), 3); 75 | assert.strictEqual(await db.count({ tags: { $nin: ['a'] } }), 1); 76 | assert.strictEqual(await db.count({ tags: { $all: ['a', 'b'] } }), 2); 77 | 78 | await db.close(); 79 | }); 80 | 81 | test('count - with existence checks', async () => { 82 | const db = await setupTestDb(); 83 | 84 | await db.insert({ field1: 'exists', field2: 'also exists' }); 85 | await db.insert({ field1: 'exists only' }); 86 | await db.insert({ different: 'field' }); 87 | 88 | assert.strictEqual(await db.count({ field1: { $exists: true } }), 2); 89 | assert.strictEqual(await db.count({ field1: { $exists: false } }), 1); 90 | assert.strictEqual(await db.count({ field2: { $exists: true } }), 1); 91 | assert.strictEqual(await db.count({ field2: { $exists: false } }), 2); 92 | 93 | await db.close(); 94 | }); 95 | 96 | test('count - with complex queries', async () => { 97 | const db = await setupTestDb(); 98 | 99 | await db.insert({ type: 'fruit', name: 'apple', color: 'red' }); 100 | await db.insert({ type: 'fruit', name: 'banana', color: 'yellow' }); 101 | await db.insert({ type: 'vegetable', name: 'carrot', color: 'orange' }); 102 | await db.insert({ type: 'vegetable', name: 'broccoli', color: 'green' }); 103 | 104 | // Multiple conditions 105 | assert.strictEqual(await db.count({ 106 | type: 'fruit', 107 | color: { $in: ['red', 'yellow'] } 108 | }), 2); 109 | 110 | // With $or operator 111 | assert.strictEqual(await db.count({ 112 | $or: [ 113 | { color: 'red' }, 114 | { color: 'green' } 115 | ] 116 | }), 2); 117 | 118 | // Complex nested query 119 | assert.strictEqual(await db.count({ 120 | type: 'vegetable', 121 | $or: [ 122 | { color: 'orange' }, 123 | { name: { $sw: 'br' } } 124 | ] 125 | }), 2); 126 | 127 | await db.close(); 128 | }); 129 | 130 | test('count - with nested fields', async () => { 131 | const db = await setupTestDb(); 132 | 133 | await db.insert({ user: { name: 'John', age: 25 } }); 134 | await db.insert({ user: { name: 'Jane', age: 30 } }); 135 | await db.insert({ user: { name: 'Bob', age: 25 } }); 136 | 137 | assert.strictEqual(await db.count({ 'user.age': 25 }), 2); 138 | assert.strictEqual(await db.count({ 'user.name': { $sw: 'J' } }), 2); 139 | 140 | await db.close(); 141 | }); 142 | 143 | test('count - after document modifications', async () => { 144 | const db = await setupTestDb(); 145 | 146 | // Insert documents 147 | await db.insert({ type: 'test', value: 1 }); 148 | await db.insert({ type: 'test', value: 2 }); 149 | assert.strictEqual(await db.count({ type: 'test' }), 2); 150 | 151 | // Remove a document 152 | const docs = await db.query({ type: 'test' }); 153 | await db.remove(docs[0].id!); 154 | assert.strictEqual(await db.count({ type: 'test' }), 1); 155 | 156 | // Update a document 157 | await db.patch(docs[1].id!, { type: 'modified' }); 158 | assert.strictEqual(await db.count({ type: 'test' }), 0); 159 | assert.strictEqual(await db.count({ type: 'modified' }), 1); 160 | 161 | await db.close(); 162 | }); 163 | 164 | test('count - with batch operations', async () => { 165 | const db = await setupTestDb(); 166 | 167 | // Batch insert 168 | await db.batchInsert([ 169 | { type: 'batch', value: 1 }, 170 | { type: 'batch', value: 2 }, 171 | { type: 'batch', value: 3 } 172 | ]); 173 | 174 | assert.strictEqual(await db.count({ type: 'batch' }), 3); 175 | assert.strictEqual(await db.count({ value: { $gt: 1 } }), 2); 176 | 177 | await db.close(); 178 | }); 179 | 180 | test('count - with empty queries', async () => { 181 | const db = await setupTestDb(); 182 | 183 | await db.insert({ value: 1 }); 184 | await db.insert({ value: 2 }); 185 | 186 | assert.strictEqual(await db.count({}), 2); 187 | assert.strictEqual(await db.count(), 2); 188 | 189 | await db.close(); 190 | }); 191 | 192 | test('count - with invalid queries', async () => { 193 | const db = await setupTestDb(); 194 | 195 | await db.insert({ value: 1 }); 196 | 197 | await assert.rejects( 198 | async () => await db.count({ $invalid: true }), 199 | /Unsupported top-level operator: \$invalid/ 200 | ); 201 | 202 | await db.close(); 203 | }); 204 | -------------------------------------------------------------------------------- /test/functionFinds.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from 'node:fs'; 2 | import { test } from 'node:test'; 3 | import { strict as assert } from 'node:assert'; 4 | import createDoubleDb from '../src/index'; 5 | 6 | const testDir: string = './testData-' + Math.random(); 7 | 8 | test.after(async () => { 9 | await fs.rm(testDir, { recursive: true, force: true }); 10 | }); 11 | 12 | async function prepareDatabase() { 13 | await fs.rm(testDir, { recursive: true }).catch(() => {}); 14 | const db = await createDoubleDb(testDir); 15 | await db.insert({ id: 'id1', a: 'a' }); 16 | await db.insert({ id: 'id2', a: 'b' }); 17 | await db.insert({ id: 'id3', a: 'c' }); 18 | await db.insert({ id: 'id4', a: 'd' }); 19 | return db; 20 | } 21 | 22 | interface Record { 23 | id: string; 24 | a: string; 25 | } 26 | 27 | test('filter - top level key found by function - with skip', async (t: any) => { 28 | const db = await prepareDatabase(); 29 | 30 | const filterRecords: Record[] = await db.filter('a', (v: string) => v >= 'c', { skip: 1 }); 31 | 32 | await db.close(); 33 | 34 | assert.deepEqual(filterRecords, [{ 35 | id: 'id4', 36 | a: 'd' 37 | }]); 38 | }); 39 | 40 | test('filter - top level key found by function - with limit', async (t: any) => { 41 | const db = await prepareDatabase(); 42 | 43 | const filterRecords: Record[] = await db.filter('a', (v: string) => v >= 'a', { limit: 1 }); 44 | 45 | await db.close(); 46 | 47 | assert.deepEqual(filterRecords, [{ 48 | id: 'id1', 49 | a: 'a' 50 | }]); 51 | }); 52 | 53 | test('find - top level key found by function - returns document', async (t: any) => { 54 | const db = await prepareDatabase(); 55 | 56 | const findRecord: Record | null = await db.find('a', (v: string) => v === 'b'); 57 | 58 | await db.close(); 59 | 60 | assert.deepEqual(findRecord, { 61 | id: 'id2', 62 | a: 'b' 63 | }); 64 | }); 65 | 66 | test('find - top level key found by function - with skip', async (t: any) => { 67 | const db = await prepareDatabase(); 68 | 69 | const findRecord: Record | null = await db.find('a', (v: string) => v >= 'a', { skip: 1 }); 70 | 71 | await db.close(); 72 | 73 | assert.deepEqual(findRecord, { 74 | id: 'id2', 75 | a: 'b' 76 | }); 77 | }); 78 | 79 | test('find - top level key found by function - gt', async (t: any) => { 80 | const db = await prepareDatabase(); 81 | 82 | const findRecord: Record | null = await db.find('a', (v: string) => v >= 'b', { gt: 'b' }); 83 | 84 | await db.close(); 85 | 86 | assert.deepEqual(findRecord, { 87 | id: 'id3', 88 | a: 'c' 89 | }); 90 | }); 91 | 92 | test('find - top level key found by function - lt', async (t: any) => { 93 | const db = await prepareDatabase(); 94 | 95 | const findRecord: Record | null = await db.find('a', (v: string) => v >= 'a', { lt: 'b' }); 96 | 97 | await db.close(); 98 | 99 | assert.deepEqual(findRecord, { 100 | id: 'id1', 101 | a: 'a' 102 | }); 103 | }); 104 | 105 | test('find - top level key found by function - gte', async (t: any) => { 106 | const db = await prepareDatabase(); 107 | 108 | const findRecord: Record | null = await db.find('a', (v: string) => v >= 'b', { gte: 'b' }); 109 | 110 | await db.close(); 111 | 112 | assert.deepEqual(findRecord, { 113 | id: 'id2', 114 | a: 'b' 115 | }); 116 | }); 117 | 118 | test('find - top level key found by function - lte', async (t: any) => { 119 | const db = await prepareDatabase(); 120 | 121 | const findRecord: Record | null = await db.find('a', (v: string) => v >= 'a', { lte: 'b' }); 122 | 123 | await db.close(); 124 | 125 | assert.deepEqual(findRecord, { 126 | id: 'id1', 127 | a: 'a' 128 | }); 129 | }); 130 | 131 | test('find - top level key found by function - lte and gte', async (t: any) => { 132 | const db = await prepareDatabase(); 133 | 134 | const findRecord: Record | null = await db.find('a', (v: string) => v === 'b', { gte: 'b', lte: 'b' }); 135 | 136 | await db.close(); 137 | 138 | assert.deepEqual(findRecord, { 139 | id: 'id2', 140 | a: 'b' 141 | }); 142 | }); 143 | 144 | test('find - top level key found by function - lt and gt', async (t: any) => { 145 | const db = await prepareDatabase(); 146 | 147 | const findRecord: Record | null = await db.find('a', (v: string) => v > 'b', { gt: 'b', lt: 'd' }); 148 | 149 | await db.close(); 150 | 151 | assert.deepEqual(findRecord, { 152 | id: 'id3', 153 | a: 'c' 154 | }); 155 | }); 156 | -------------------------------------------------------------------------------- /test/index.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from 'node:fs'; 2 | import { after, test } from 'node:test'; 3 | import { strict as assert } from 'node:assert'; 4 | import createDoubleDb from '../dist/index.js'; 5 | 6 | const testDir = './testData-' + Math.random(); 7 | 8 | after(async () => { 9 | await fs.rm(testDir, { recursive: true, force: true }); 10 | }); 11 | 12 | test('find - top level key found - returns document', async () => { 13 | await fs.rm(testDir, { recursive: true }).catch(() => {}); 14 | const db = await createDoubleDb(testDir); 15 | await db.insert({ id: 'id1', a: 1 }); 16 | await db.insert({ id: 'id2', a: 2 }); 17 | await db.insert({ id: 'id3', a: 3 }); 18 | await db.insert({ id: 'id4', a: 4 }); 19 | 20 | const findRecord = await db.find('a', 2); 21 | 22 | await db.close(); 23 | 24 | assert.deepStrictEqual(findRecord, { 25 | id: 'id2', 26 | a: 2 27 | }); 28 | }); 29 | 30 | test('find - with skip', async () => { 31 | await fs.rm(testDir, { recursive: true }).catch(() => {}); 32 | const db = await createDoubleDb(testDir); 33 | await db.insert({ id: 'id1', a: 1 }); 34 | await db.insert({ id: 'id2', a: 1 }); 35 | await db.insert({ id: 'id3', a: 1 }); 36 | await db.insert({ id: 'id4', a: 1 }); 37 | 38 | const findRecord = await db.find('a', 1, { skip: 2 }); 39 | 40 | await db.close(); 41 | 42 | assert.deepStrictEqual(findRecord, { 43 | id: 'id3', 44 | a: 1 45 | }); 46 | }); 47 | 48 | test('find - nested key found - returns document', async () => { 49 | await fs.rm(testDir, { recursive: true }).catch(() => {}); 50 | const db = await createDoubleDb(testDir); 51 | await db.insert({ id: 'id1', a: { b: 1 } }); 52 | await db.insert({ id: 'id2', a: { b: 2 } }); 53 | await db.insert({ id: 'id3', a: { b: 3 } }); 54 | await db.insert({ id: 'id4', a: { b: 4 } }); 55 | 56 | const findRecord = await db.find('a.b', 2); 57 | 58 | await db.close(); 59 | 60 | assert.deepStrictEqual(findRecord, { 61 | id: 'id2', 62 | a: { 63 | b: 2 64 | } 65 | }); 66 | }); 67 | 68 | test('find - array top level key found - returns document', async () => { 69 | await fs.rm(testDir, { recursive: true }).catch(() => {}); 70 | const db = await createDoubleDb(testDir); 71 | await db.insert({ id: 'id1', a: [1, 2] }); 72 | await db.insert({ id: 'id2', a: [2, 3] }); 73 | await db.insert({ id: 'id3', a: [3, 4] }); 74 | await db.insert({ id: 'id4', a: [4, 5] }); 75 | 76 | const findRecord = await db.find('a', 2); 77 | 78 | await db.close(); 79 | 80 | assert.deepStrictEqual(findRecord, { 81 | id: 'id1', 82 | a: [1, 2] 83 | }); 84 | }); 85 | 86 | test('filter - with skip', async () => { 87 | await fs.rm(testDir, { recursive: true }).catch(() => {}); 88 | const db = await createDoubleDb(testDir); 89 | await db.insert({ id: 'id1', a: 1 }); 90 | await db.insert({ id: 'id2', a: 1, b: 1 }); 91 | await db.insert({ id: 'id3', a: 1, b: 1 }); 92 | await db.insert({ id: 'id4', a: 1 }); 93 | 94 | const filterRecords = await db.filter('b', 1, { skip: 1 }); 95 | 96 | await db.close(); 97 | 98 | assert.deepStrictEqual(filterRecords, [ 99 | { 100 | a: 1, 101 | b: 1, 102 | id: 'id3' 103 | } 104 | ]); 105 | }); 106 | 107 | test('filter - with limit', async () => { 108 | await fs.rm(testDir, { recursive: true }).catch(() => {}); 109 | const db = await createDoubleDb(testDir); 110 | await db.insert({ id: 'id1', a: 1 }); 111 | await db.insert({ id: 'id2', a: 1, b: 1 }); 112 | await db.insert({ id: 'id3', a: 1, b: 1 }); 113 | await db.insert({ id: 'id4', a: 1 }); 114 | 115 | const filterRecords = await db.filter('b', 1, { limit: 1 }); 116 | 117 | await db.close(); 118 | 119 | assert.deepStrictEqual(filterRecords, [ 120 | { 121 | a: 1, 122 | b: 1, 123 | id: 'id2' 124 | } 125 | ]); 126 | }); 127 | 128 | test('filter - top level key found - returns documents', async () => { 129 | await fs.rm(testDir, { recursive: true }).catch(() => {}); 130 | const db = await createDoubleDb(testDir); 131 | await db.insert({ id: 'id1', a: 1 }); 132 | await db.insert({ id: 'id2', a: 2, b: 1 }); 133 | await db.insert({ id: 'id3', a: 2, b: 2 }); 134 | await db.insert({ id: 'id4', a: 4 }); 135 | 136 | const filterRecords = await db.filter('a', 2); 137 | 138 | await db.close(); 139 | 140 | assert.deepStrictEqual(filterRecords, [ 141 | { 142 | a: 2, 143 | b: 1, 144 | id: 'id2' 145 | }, 146 | { 147 | a: 2, 148 | b: 2, 149 | id: 'id3' 150 | } 151 | ]); 152 | }); 153 | 154 | test('read - no id supplied - throws', async () => { 155 | await fs.rm(testDir, { recursive: true }).catch(() => {}); 156 | const db = await createDoubleDb(testDir); 157 | 158 | try { 159 | await db.read(); 160 | } catch (error) { 161 | await db.close(); 162 | assert.strictEqual((error as Error).message, 'Key cannot be null or undefined'); 163 | } 164 | }); 165 | 166 | test('read - not found - returns undefined', async () => { 167 | await fs.rm(testDir, { recursive: true }).catch(() => {}); 168 | const db = await createDoubleDb(testDir); 169 | const readRecord = await db.read('nothing in here'); 170 | await db.close(); 171 | 172 | assert.strictEqual(readRecord, undefined); 173 | }); 174 | 175 | test('read - found - returns document', async () => { 176 | await fs.rm(testDir, { recursive: true }).catch(() => {}); 177 | const db = await createDoubleDb(testDir); 178 | const insertedRecord = await db.insert({ 179 | a: 1 180 | }); 181 | 182 | const readRecord = await db.read(insertedRecord.id); 183 | 184 | await db.close(); 185 | 186 | assert.strictEqual(readRecord.a, 1); 187 | }); 188 | 189 | test('create - existing key - throws', async () => { 190 | await fs.rm(testDir, { recursive: true }).catch(() => {}); 191 | const db = await createDoubleDb(testDir); 192 | await db.insert({ 193 | id: 'myid', 194 | a: 1 195 | }); 196 | 197 | try { 198 | await db.insert({ 199 | id: 'myid', 200 | a: 1 201 | }); 202 | } catch (error) { 203 | assert.strictEqual((error as Error).message, 'doubledb.insert: document with id myid already exists'); 204 | } 205 | 206 | await db.close(); 207 | }); 208 | 209 | test('create - missing arguments - throws', async () => { 210 | await fs.rm(testDir, { recursive: true }).catch(() => {}); 211 | const db = await createDoubleDb(testDir); 212 | 213 | try { 214 | await db.insert(); 215 | } catch (error) { 216 | await db.close(); 217 | assert.strictEqual((error as Error).message, 'doubledb.insert: no document was supplied to insert function'); 218 | } 219 | }); 220 | 221 | test('replace - missing id argument - throws', async () => { 222 | await fs.rm(testDir, { recursive: true }).catch(() => {}); 223 | const db = await createDoubleDb(testDir); 224 | 225 | try { 226 | await db.replace(null); 227 | } catch (error) { 228 | await db.close(); 229 | assert.strictEqual((error as Error).message, 'doubledb.replace: no id was supplied to replace function'); 230 | } 231 | }); 232 | 233 | test('replace - missing newDocument argument - throws', async () => { 234 | await fs.rm(testDir, { recursive: true }).catch(() => {}); 235 | const db = await createDoubleDb(testDir); 236 | 237 | try { 238 | await db.replace(1); 239 | } catch (error) { 240 | await db.close(); 241 | assert.strictEqual((error as Error).message, 'doubledb.replace: no newDocument was supplied to replace function'); 242 | } 243 | }); 244 | 245 | test('replace - none matching id and newDocument.id - throws', async () => { 246 | await fs.rm(testDir, { recursive: true }).catch(() => {}); 247 | const db = await createDoubleDb(testDir); 248 | 249 | try { 250 | await db.replace(1, { id: 2 }); 251 | } catch (error) { 252 | await db.close(); 253 | assert.strictEqual((error as Error).message, 'doubledb.replace: the id (1) and newDocument.id (2) must be the same, or not defined'); 254 | } 255 | }); 256 | 257 | test('replace - not found - throws', async () => { 258 | await fs.rm(testDir, { recursive: true }).catch(() => {}); 259 | const db = await createDoubleDb(testDir); 260 | 261 | try { 262 | await db.replace(1, { a: 1 }); 263 | } catch (error) { 264 | await db.close(); 265 | assert.strictEqual((error as Error).message, 'doubledb.replace: document with id 1 does not exist'); 266 | } 267 | }); 268 | 269 | test('replace - found - returns new document', async () => { 270 | await fs.rm(testDir, { recursive: true }).catch(() => {}); 271 | const db = await createDoubleDb(testDir); 272 | const insertedRecord = await db.insert({ 273 | a: 1 274 | }); 275 | 276 | const updatedRecord = await db.replace(insertedRecord.id, { 277 | a: 2 278 | }); 279 | 280 | const readRecord = await db.read(insertedRecord.id); 281 | 282 | await db.close(); 283 | 284 | assert.strictEqual(updatedRecord.id, insertedRecord.id); 285 | assert.strictEqual(updatedRecord.a, 2); 286 | assert.strictEqual(readRecord.a, 2); 287 | }); 288 | 289 | test('patch - missing id argument - throws', async () => { 290 | await fs.rm(testDir, { recursive: true }).catch(() => {}); 291 | const db = await createDoubleDb(testDir); 292 | 293 | try { 294 | await db.patch(null); 295 | } catch (error) { 296 | await db.close(); 297 | assert.strictEqual((error as Error).message, 'doubledb.patch: no id was supplied to patch function'); 298 | } 299 | }); 300 | 301 | test('patch - missing newDocument argument - throws', async () => { 302 | await fs.rm(testDir, { recursive: true }).catch(() => {}); 303 | const db = await createDoubleDb(testDir); 304 | 305 | try { 306 | await db.patch(1); 307 | } catch (error) { 308 | await db.close(); 309 | assert.strictEqual((error as Error).message, 'doubledb.patch: no newDocument was supplied to patch function'); 310 | } 311 | }); 312 | 313 | test('patch - none matching id and newDocument.id - throws', async () => { 314 | await fs.rm(testDir, { recursive: true }).catch(() => {}); 315 | const db = await createDoubleDb(testDir); 316 | 317 | try { 318 | await db.patch(1, { id: 2 }); 319 | } catch (error) { 320 | await db.close(); 321 | assert.strictEqual((error as Error).message, 'doubledb.patch: the id (1) and newDocument.id (2) must be the same, or not defined'); 322 | } 323 | }); 324 | 325 | test('patch - not found - throws', async () => { 326 | await fs.rm(testDir, { recursive: true }).catch(() => {}); 327 | const db = await createDoubleDb(testDir); 328 | 329 | try { 330 | await db.patch(1, { a: 1 }); 331 | } catch (error) { 332 | await db.close(); 333 | assert.strictEqual((error as Error).message, 'doubledb.patch: document with id 1 does not exist'); 334 | } 335 | }); 336 | 337 | test('patch - found - returns patched document', async () => { 338 | await fs.rm(testDir, { recursive: true }).catch(() => {}); 339 | const db = await createDoubleDb(testDir); 340 | const insertedRecord = await db.insert({ 341 | a: 1, 342 | b: 2 343 | }); 344 | 345 | const updatedRecord = await db.patch(insertedRecord.id, { 346 | b: 3, 347 | c: 4 348 | }); 349 | 350 | const readRecord = await db.read(insertedRecord.id); 351 | 352 | await db.close(); 353 | 354 | assert.deepStrictEqual(updatedRecord, { 355 | id: insertedRecord.id, 356 | a: 1, 357 | b: 3, 358 | c: 4 359 | }); 360 | 361 | assert.deepStrictEqual(readRecord, { 362 | id: insertedRecord.id, 363 | a: 1, 364 | b: 3, 365 | c: 4 366 | }); 367 | }); 368 | 369 | test('remove - missing id argument - throws', async () => { 370 | await fs.rm(testDir, { recursive: true }).catch(() => {}); 371 | const db = await createDoubleDb(testDir); 372 | 373 | try { 374 | await db.remove(null); 375 | } catch (error) { 376 | await db.close(); 377 | assert.strictEqual((error as Error).message, 'doubledb.remove: no id was supplied to replace function'); 378 | } 379 | }); 380 | 381 | test('remove - not found - throws', async () => { 382 | await fs.rm(testDir, { recursive: true }).catch(() => {}); 383 | const db = await createDoubleDb(testDir); 384 | 385 | try { 386 | await db.remove('nothing'); 387 | } catch (error) { 388 | await db.close(); 389 | assert.strictEqual((error as Error).message, 'doubledb.remove: document with id nothing does not exist'); 390 | } 391 | }); 392 | 393 | test('remove - found - removes document', async () => { 394 | await fs.rm(testDir, { recursive: true }).catch(() => {}); 395 | const db = await createDoubleDb(testDir); 396 | const insertedRecord = await db.insert({ 397 | a: 1 398 | }); 399 | const readBefore = await db.read(insertedRecord.id); 400 | const removeResult = await db.remove(insertedRecord.id); 401 | const readAfter = await db.read(insertedRecord.id); 402 | 403 | await db.close(); 404 | 405 | assert.strictEqual(readBefore.id, insertedRecord.id); 406 | assert.strictEqual(readAfter, undefined); 407 | assert.strictEqual(removeResult, undefined); 408 | }); 409 | 410 | test('batchInsert - inserts multiple documents at once', async () => { 411 | await fs.rm(testDir, { recursive: true }).catch(() => {}); 412 | const db = await createDoubleDb(testDir); 413 | 414 | const docsToInsert = [ 415 | { id: 'batch1', value: 10 }, 416 | { id: 'batch2', value: 20 }, 417 | { id: 'batch3', value: 30 }, 418 | ]; 419 | const insertedDocs = await db.batchInsert(docsToInsert); 420 | 421 | assert.strictEqual(insertedDocs.length, 3); 422 | assert.deepStrictEqual(await db.read('batch1'), docsToInsert[0]); 423 | assert.deepStrictEqual(await db.read('batch2'), docsToInsert[1]); 424 | assert.deepStrictEqual(await db.read('batch3'), docsToInsert[2]); 425 | 426 | await db.close(); 427 | }); 428 | 429 | test('batchInsert - empty array - throws error', async () => { 430 | await fs.rm(testDir, { recursive: true }).catch(() => {}); 431 | const db = await createDoubleDb(testDir); 432 | 433 | try { 434 | await db.batchInsert([]); 435 | } catch (error) { 436 | await db.close(); 437 | assert.strictEqual((error as Error).message, 'doubledb.batchInsert: documents must be a non-empty array'); 438 | } 439 | }); 440 | 441 | test('upsert - inserts new document if not exists', async () => { 442 | await fs.rm(testDir, { recursive: true }).catch(() => {}); 443 | const db = await createDoubleDb(testDir); 444 | 445 | const upsertedRecord = await db.upsert('id1', { a: 1 }); 446 | 447 | const readRecord = await db.read('id1'); 448 | 449 | await db.close(); 450 | 451 | assert.deepStrictEqual(upsertedRecord, { 452 | id: 'id1', 453 | a: 1 454 | }); 455 | 456 | assert.deepStrictEqual(readRecord, { 457 | id: 'id1', 458 | a: 1 459 | }); 460 | }); 461 | 462 | test('upsert - updates existing document if exists', async () => { 463 | await fs.rm(testDir, { recursive: true }).catch(() => {}); 464 | const db = await createDoubleDb(testDir); 465 | 466 | await db.insert({ id: 'id1', a: 1 }); 467 | 468 | const upsertedRecord = await db.upsert('id1', { a: 2, b: 3 }); 469 | 470 | const readRecord = await db.read('id1'); 471 | 472 | await db.close(); 473 | 474 | assert.deepStrictEqual(upsertedRecord, { 475 | id: 'id1', 476 | a: 2, 477 | b: 3 478 | }); 479 | 480 | assert.deepStrictEqual(readRecord, { 481 | id: 'id1', 482 | a: 2, 483 | b: 3 484 | }); 485 | }); 486 | -------------------------------------------------------------------------------- /test/indexes.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from 'node:fs'; 2 | import test from 'node:test'; 3 | import { strict as assert } from 'node:assert'; 4 | import createDoubleDb from '../src/index'; 5 | 6 | const testDir = './testData-' + Math.random(); 7 | 8 | test.after(async () => { 9 | await fs.rm(testDir, { recursive: true, force: true }); 10 | }); 11 | 12 | test('indexes - single level - stores correct indexes', async (t) => { 13 | await t.test('single level indexes', async () => { 14 | await fs.rm(testDir, { recursive: true }).catch(() => {}); 15 | const db = await createDoubleDb(testDir); 16 | 17 | await db.insert({ 18 | id: 'myid', 19 | testStringA: 'this is test a', 20 | testStringB: 'this is test a', 21 | testNumberA: 1, 22 | testNumberB: 2, 23 | nested: { 24 | a: 1 25 | }, 26 | array: ['testa', 'testb'] 27 | }); 28 | 29 | const indexes: Array<{ key: string, value: string }> = []; 30 | 31 | for await (const [key, value] of db._level.iterator({ gt: 'indexes.', lt: 'indexes~' })) { 32 | indexes.push({ key, value }); 33 | } 34 | 35 | db.close(); 36 | 37 | assert.deepStrictEqual(indexes, [ 38 | { key: 'indexes.array=testa|myid', value: 'myid' }, 39 | { key: 'indexes.array=testb|myid', value: 'myid' }, 40 | { key: 'indexes.id=myid|myid', value: 'myid' }, 41 | { key: 'indexes.nested.a=1|myid', value: 'myid' }, 42 | { key: 'indexes.testNumberA=1|myid', value: 'myid' }, 43 | { key: 'indexes.testNumberB=2|myid', value: 'myid' }, 44 | { key: 'indexes.testStringA=this is test a|myid', value: 'myid' }, 45 | { key: 'indexes.testStringB=this is test a|myid', value: 'myid' } 46 | ]); 47 | }); 48 | }); 49 | 50 | test('replace - updates indexes correctly', async (t) => { 51 | await t.test('replace operation', async () => { 52 | await fs.rm(testDir, { recursive: true }).catch(() => {}); 53 | const db = await createDoubleDb(testDir); 54 | 55 | const insertedRecord = await db.insert({ 56 | firstName: 'John', 57 | lastName: 'Doe', 58 | age: 30 59 | }); 60 | 61 | await db.replace(insertedRecord.id, { 62 | firstName: 'Jane', 63 | lastName: 'Smith', 64 | occupation: 'Engineer' 65 | }); 66 | 67 | const oldIndexes = await db.find('age', 30); 68 | assert.strictEqual(oldIndexes, undefined, 'Old index should be removed'); 69 | 70 | const newFirstNameIndex = await db.find('firstName', 'Jane'); 71 | assert.deepStrictEqual(newFirstNameIndex, { id: insertedRecord.id, firstName: 'Jane', lastName: 'Smith', occupation: 'Engineer' }, 'New firstName index should exist'); 72 | 73 | const newLastNameIndex = await db.find('lastName', 'Smith'); 74 | assert.deepStrictEqual(newLastNameIndex, { id: insertedRecord.id, firstName: 'Jane', lastName: 'Smith', occupation: 'Engineer' }, 'New lastName index should exist'); 75 | 76 | const newOccupationIndex = await db.find('occupation', 'Engineer'); 77 | assert.deepStrictEqual(newOccupationIndex, { id: insertedRecord.id, firstName: 'Jane', lastName: 'Smith', occupation: 'Engineer' }, 'New occupation index should exist'); 78 | 79 | await db.close(); 80 | }); 81 | }); 82 | 83 | test('patch - updates indexes correctly', async (t) => { 84 | await t.test('patch operation', async () => { 85 | await fs.rm(testDir, { recursive: true }).catch(() => {}); 86 | const db = await createDoubleDb(testDir); 87 | 88 | const insertedRecord = await db.insert({ 89 | firstName: 'John', 90 | lastName: 'Doe', 91 | age: 30 92 | }); 93 | 94 | await db.patch(insertedRecord.id, { 95 | firstName: 'Jane', 96 | age: 31, 97 | occupation: 'Engineer' 98 | }); 99 | 100 | const updatedFirstNameIndex = await db.find('firstName', 'Jane'); 101 | assert.deepStrictEqual(updatedFirstNameIndex, { id: insertedRecord.id, firstName: 'Jane', lastName: 'Doe', age: 31, occupation: 'Engineer' }, 'Updated firstName index should exist'); 102 | 103 | const updatedAgeIndex = await db.find('age', 31); 104 | assert.deepStrictEqual(updatedAgeIndex, { id: insertedRecord.id, firstName: 'Jane', lastName: 'Doe', age: 31, occupation: 'Engineer' }, 'Updated age index should exist'); 105 | 106 | const newOccupationIndex = await db.find('occupation', 'Engineer'); 107 | assert.deepStrictEqual(newOccupationIndex, { id: insertedRecord.id, firstName: 'Jane', lastName: 'Doe', age: 31, occupation: 'Engineer' }, 'New occupation index should exist'); 108 | 109 | const unchangedLastNameIndex = await db.find('lastName', 'Doe'); 110 | assert.deepStrictEqual(unchangedLastNameIndex, { id: insertedRecord.id, firstName: 'Jane', lastName: 'Doe', age: 31, occupation: 'Engineer' }, 'Unchanged lastName index should still exist'); 111 | 112 | const oldAgeIndex = await db.find('age', 30); 113 | assert.strictEqual(oldAgeIndex, undefined, 'Old age index should be removed'); 114 | 115 | await db.close(); 116 | }); 117 | }); 118 | 119 | test('remove - cleans up indexes correctly', async (t) => { 120 | await t.test('remove operation', async () => { 121 | await fs.rm(testDir, { recursive: true }).catch(() => {}); 122 | const db = await createDoubleDb(testDir); 123 | 124 | const insertedRecord = await db.insert({ 125 | firstName: 'John', 126 | lastName: 'Doe', 127 | age: 30 128 | }); 129 | 130 | await db.remove(insertedRecord.id); 131 | 132 | const firstNameIndex = await db.find('firstName', 'John'); 133 | assert.strictEqual(firstNameIndex, undefined, 'firstName index should be removed'); 134 | 135 | const lastNameIndex = await db.find('lastName', 'Doe'); 136 | assert.strictEqual(lastNameIndex, undefined, 'lastName index should be removed'); 137 | 138 | const ageIndex = await db.find('age', 30); 139 | assert.strictEqual(ageIndex, undefined, 'age index should be removed'); 140 | 141 | const removedDocument = await db.read(insertedRecord.id); 142 | assert.strictEqual(removedDocument, undefined, 'Document should be removed'); 143 | 144 | await db.close(); 145 | }); 146 | }); 147 | -------------------------------------------------------------------------------- /test/query.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from 'fs'; 2 | import test from 'node:test'; 3 | import { strict as assert } from 'node:assert'; 4 | import createDoubleDb from '../src/index'; 5 | 6 | const testDir = './testData-' + Math.random(); 7 | 8 | test.after(async () => { 9 | await fs.rm(testDir, { recursive: true, force: true }); 10 | }); 11 | 12 | async function setupTestDb() { 13 | await fs.rm(testDir, { recursive: true }).catch(() => {}); 14 | const db = await createDoubleDb(testDir); 15 | return db; 16 | } 17 | 18 | test('empty query', async () => { 19 | const db = await setupTestDb(); 20 | await db.insert({ value: 'alpha' }); 21 | await db.insert({ value: 'beta' }); 22 | const result = await db.query({}); 23 | assert.strictEqual(result.length, 2); 24 | result.sort((a, b) => a.value < b.value ? -1 : 1); 25 | assert.strictEqual(result[0].value, 'alpha'); 26 | assert.strictEqual(result[1].value, 'beta'); 27 | await db.close(); 28 | }); 29 | 30 | test('special characters', async () => { 31 | const db = await setupTestDb(); 32 | await db.insert({ id: 'users/mark', name: 'Mark' }); 33 | await db.insert({ id: 'users/joe', name: 'Joe' }); 34 | await db.insert({ id: 'users/mary', name: 'Mary' }); 35 | await db.insert({ id: 'groups/standard', name: 'Standard' }); 36 | await db.insert({ id: 'groups/admin', name: 'Admin' }); 37 | await db.insert({ id: 'rootD', name: 'rootD' }); 38 | 39 | const result = await db.query(); 40 | assert.strictEqual(result.length, 6); 41 | await db.close(); 42 | }); 43 | 44 | test('$sw operator on string', async () => { 45 | const db = await setupTestDb(); 46 | await db.insert({ value: 'alpha' }); 47 | await db.insert({ value: 'beta' }); 48 | await db.insert({ value: 'chi' }); 49 | const result = await db.query({ value: { $sw: 'b' } }); 50 | assert.strictEqual(result.length, 1); 51 | assert.strictEqual(result[0].value, 'beta'); 52 | await db.close(); 53 | }); 54 | 55 | test('$eq operator', async () => { 56 | const db = await setupTestDb(); 57 | await db.insert({ value: 5 }); 58 | await db.insert({ value: 10 }); 59 | const result = await db.query({ value: { $eq: 5 } }); 60 | assert.strictEqual(result.length, 1); 61 | assert.strictEqual(result[0].value, 5); 62 | await db.close(); 63 | }); 64 | 65 | test('$ne operator', async () => { 66 | const db = await setupTestDb(); 67 | await db.insert({ value: 5 }); 68 | await db.insert({ value: 10 }); 69 | const result = await db.query({ value: { $ne: 5 } }); 70 | assert.strictEqual(result.length, 1); 71 | assert.strictEqual(result[0].value, 10); 72 | await db.close(); 73 | }); 74 | 75 | test('$gt operator', async () => { 76 | const db = await setupTestDb(); 77 | await db.insert({ value: 5 }); 78 | await db.insert({ value: 10 }); 79 | const result = await db.query({ value: { $gt: 7 } }); 80 | assert.strictEqual(result.length, 1); 81 | assert.strictEqual(result[0].value, 10); 82 | await db.close(); 83 | }); 84 | 85 | test('$gte operator', async () => { 86 | const db = await setupTestDb(); 87 | await db.insert({ value: 5 }); 88 | await db.insert({ value: 10 }); 89 | const result = await db.query({ value: { $gte: 5 } }); 90 | assert.strictEqual(result.length, 2); 91 | await db.close(); 92 | }); 93 | 94 | test('$lt operator', async () => { 95 | const db = await setupTestDb(); 96 | await db.insert({ value: 5 }); 97 | await db.insert({ value: 10 }); 98 | const result = await db.query({ value: { $lt: 7 } }); 99 | assert.strictEqual(result.length, 1); 100 | assert.strictEqual(result[0].value, 5); 101 | await db.close(); 102 | }); 103 | 104 | test('$lte operator', async () => { 105 | const db = await setupTestDb(); 106 | await db.insert({ value: 5 }); 107 | await db.insert({ value: 10 }); 108 | const result = await db.query({ value: { $lte: 10 } }); 109 | assert.strictEqual(result.length, 2); 110 | await db.close(); 111 | }); 112 | 113 | test('$in operator', async () => { 114 | const db = await setupTestDb(); 115 | await db.insert({ value: 5 }); 116 | await db.insert({ value: 10 }); 117 | const result = await db.query({ value: { $in: [5, 15] } }); 118 | assert.strictEqual(result.length, 1); 119 | assert.strictEqual(result[0].value, 5); 120 | await db.close(); 121 | }); 122 | 123 | test('$nin operator', async () => { 124 | const db = await setupTestDb(); 125 | await db.insert({ value: 5 }); 126 | await db.insert({ value: 10 }); 127 | const result = await db.query({ value: { $nin: [5, 15] } }); 128 | assert.strictEqual(result.length, 1); 129 | assert.strictEqual(result[0].value, 10); 130 | await db.close(); 131 | }); 132 | 133 | test('$exists operator', async () => { 134 | const db = await setupTestDb(); 135 | await db.insert({ value: 5 }); 136 | await db.insert({ another: 10 }); 137 | const result = await db.query({ value: { $exists: true } }); 138 | assert.strictEqual(result.length, 1); 139 | assert.strictEqual(result[0].value, 5); 140 | await db.close(); 141 | }); 142 | 143 | test('$all operator', async () => { 144 | const db = await setupTestDb(); 145 | await db.insert({ value: [1, 2, 3] }); 146 | await db.insert({ value: [1, 2] }); 147 | const result = await db.query({ value: { $all: [1, 2, 3] } }); 148 | assert.strictEqual(result.length, 1); 149 | assert.deepStrictEqual(result[0].value, [1, 2, 3]); 150 | await db.close(); 151 | }); 152 | 153 | test('$not operator', async () => { 154 | const db = await setupTestDb(); 155 | await db.insert({ value: 5 }); 156 | await db.insert({ value: 10 }); 157 | const result = await db.query({ value: { $not: { $gt: 7 } } }); 158 | assert.strictEqual(result.length, 1); 159 | assert.strictEqual(result[0].value, 5); 160 | await db.close(); 161 | }); 162 | 163 | test('Complex query with $or', async () => { 164 | const db = await setupTestDb(); 165 | await db.insert({ category: 'a', firstName: 'Joe' }); 166 | await db.insert({ category: 'b', firstName: 'Nope' }); 167 | await db.insert({ category: 'b', firstName: 'Joe' }); 168 | const result = await db.query({ 169 | category: 'b', 170 | $or: [ 171 | { firstName: { $eq: 'Joe' } }, 172 | { firstName: { $eq: 'joe' } } 173 | ] 174 | }); 175 | assert.strictEqual(result.length, 1); 176 | assert.strictEqual(result[0].firstName, 'Joe'); 177 | assert.strictEqual(result[0].category, 'b'); 178 | await db.close(); 179 | }); 180 | 181 | test('query limit, offset, sort', async () => { 182 | const db = await setupTestDb(); 183 | for (let i = 0; i < 10; i++) { 184 | await db.insert({ value: i }); 185 | } 186 | const result = await db.query({}, { 187 | limit: 3, 188 | offset: 2, 189 | sort: { 190 | value: 1 191 | } 192 | }); 193 | assert.strictEqual(result.length, 3); 194 | assert.strictEqual(result[0].value, 2); 195 | assert.strictEqual(result[1].value, 3); 196 | assert.strictEqual(result[2].value, 4); 197 | 198 | await db.close(); 199 | }); 200 | 201 | test('query sort with nested fields', async () => { 202 | const db = await setupTestDb(); 203 | await db.insert({ value: { category: 2, name: 'B' } }); 204 | await db.insert({ value: { category: 1, name: 'A' } }); 205 | await db.insert({ value: { category: 1, name: 'C' } }); 206 | await db.insert({ value: { category: 2, name: 'D' } }); 207 | 208 | const result = await db.query({}, { 209 | sort: { 210 | 'value.category': 1, 211 | 'value.name': 1 212 | } 213 | }); 214 | 215 | assert.strictEqual(result.length, 4); 216 | assert.strictEqual(result[0].value.category, 1); 217 | assert.strictEqual(result[0].value.name, 'A'); 218 | assert.strictEqual(result[1].value.category, 1); 219 | assert.strictEqual(result[1].value.name, 'C'); 220 | assert.strictEqual(result[2].value.category, 2); 221 | assert.strictEqual(result[2].value.name, 'B'); 222 | assert.strictEqual(result[3].value.category, 2); 223 | assert.strictEqual(result[3].value.name, 'D'); 224 | 225 | await db.close(); 226 | }); 227 | 228 | test('query with sort option', async () => { 229 | const db = await setupTestDb(); 230 | await db.insert({ value: 'gamma' }); 231 | await db.insert({ value: 'alpha' }); 232 | await db.insert({ value: 'beta' }); 233 | const result = await db.query({}, { sort: { value: 1 } }); 234 | assert.strictEqual(result.length, 3); 235 | assert.strictEqual(result[0].value, 'alpha'); 236 | assert.strictEqual(result[1].value, 'beta'); 237 | assert.strictEqual(result[2].value, 'gamma'); 238 | await db.close(); 239 | }); 240 | 241 | test('query with project option', async () => { 242 | const db = await setupTestDb(); 243 | await db.insert({ value: 'gamma', extra: 'data' }); 244 | await db.insert({ value: 'alpha', extra: 'info' }); 245 | const result = await db.query({}, { project: { value: 1 } }); 246 | 247 | result.sort((a, b) => a.value < b.value ? -1 : 1); 248 | assert.strictEqual(result.length, 2); 249 | assert.strictEqual(Object.keys(result[0]).length, 1); 250 | assert.strictEqual(result[0].value, 'alpha'); 251 | assert.strictEqual(Object.keys(result[1]).length, 1); 252 | assert.strictEqual(result[1].value, 'gamma'); 253 | 254 | await db.close(); 255 | }); 256 | 257 | test('query with sort and project options', async () => { 258 | const db = await setupTestDb(); 259 | await db.insert({ value: 'gamma', extra: 'data' }); 260 | await db.insert({ value: 'alpha', extra: 'info' }); 261 | await db.insert({ value: 'beta', extra: 'details' }); 262 | const result = await db.query({}, { sort: { value: 1 }, project: { value: 1 } }); 263 | assert.strictEqual(result.length, 3); 264 | assert.strictEqual(Object.keys(result[0]).length, 1); 265 | assert.strictEqual(result[0].value, 'alpha'); 266 | assert.strictEqual(Object.keys(result[1]).length, 1); 267 | assert.strictEqual(result[1].value, 'beta'); 268 | assert.strictEqual(Object.keys(result[2]).length, 1); 269 | assert.strictEqual(result[2].value, 'gamma'); 270 | await db.close(); 271 | }); 272 | 273 | -------------------------------------------------------------------------------- /test/stress.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from 'fs'; 2 | import test from 'node:test'; 3 | import { strict as assert } from 'node:assert'; 4 | import createDoubleDb, { DoubleDb } from '../src/index'; 5 | 6 | const testDir = './testData-' + Math.random(); 7 | 8 | test.after(async () => { 9 | await fs.rm(testDir, { recursive: true, force: true }); 10 | }); 11 | 12 | test.skip('stress test', async (t) => { 13 | await t.test('stress test operations', async () => { 14 | await fs.rm(testDir, { recursive: true }).catch(() => {}); 15 | const db: DoubleDb = await createDoubleDb(testDir); 16 | 17 | let id = 1; 18 | { 19 | const startTime = Date.now(); 20 | for (let x = 0; x < 100; x++) { 21 | const actions: Array<{ type: string; key: string | number; value: string | number }> = []; 22 | console.log(x); 23 | for (let y = 0; y < 10000; y++) { 24 | id++; 25 | actions.push({ 26 | type: 'put', 27 | key: id, 28 | value: JSON.stringify({ 29 | id, 30 | a: 1 31 | }) 32 | }); 33 | actions.push({ 34 | type: 'put', 35 | key: `indexes.a=1|${id}`, 36 | value: id 37 | }); 38 | actions.push({ 39 | type: 'put', 40 | key: `indexes.id=${id}|${id}`, 41 | value: id 42 | }); 43 | } 44 | await db._level.batch(actions); 45 | } 46 | const endTime = Date.now(); 47 | 48 | console.log('Total inserted', id, 'in', endTime - startTime, 'ms'); 49 | assert.ok(true); 50 | } 51 | 52 | { 53 | const middleId = Math.floor(id / 2); 54 | const startTime = Date.now(); 55 | const findRecords = await Promise.all([ 56 | db.find('id', middleId), 57 | db.find('id', middleId + 20), 58 | db.find('id', middleId - 100) 59 | ]); 60 | const endTime = Date.now(); 61 | 62 | console.log('Found', findRecords.length, 'in', endTime - startTime, 'ms'); 63 | assert.deepStrictEqual(findRecords, [ 64 | { 65 | a: 1, 66 | id: middleId 67 | }, 68 | { 69 | a: 1, 70 | id: middleId + 20 71 | }, 72 | { 73 | a: 1, 74 | id: middleId - 100 75 | } 76 | ]); 77 | } 78 | 79 | { 80 | const middleId = Math.floor(id / 2); 81 | const startTime = Date.now(); 82 | const readRecord = await db.read(Math.floor(middleId / 2)); 83 | const endTime = Date.now(); 84 | 85 | console.log('Read id', id, 'in', endTime - startTime, 'ms'); 86 | assert.deepStrictEqual(readRecord, { 87 | id: Math.floor(middleId / 2), 88 | a: 1 89 | }); 90 | } 91 | 92 | await db.close(); 93 | }); 94 | }); 95 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "ESNext", 4 | "esModuleInterop": true, 5 | "allowSyntheticDefaultImports": true, 6 | "target": "esnext", 7 | "noImplicitAny": true, 8 | "moduleResolution": "node", 9 | "sourceMap": true, 10 | "outDir": "dist", 11 | "declaration": true, 12 | "baseUrl": ".", 13 | "paths": { 14 | "*": [ 15 | "node_modules/*", 16 | "src/types/*" 17 | ] 18 | } 19 | }, 20 | "include": [ 21 | "src/**/*" 22 | ] 23 | } 24 | --------------------------------------------------------------------------------