├── .npmignore ├── README.md ├── src ├── index.ts ├── db.ts └── collection.ts ├── tsconfig.lib.json ├── tsconfig.json ├── package.json └── .gitignore /.npmignore: -------------------------------------------------------------------------------- 1 | *.r2r 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # bson-lite 2 | 3 | File-based storage for BSON, with unique indexes and joining support. 4 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { Collection, joinCollection } from "./collection"; 2 | export { Db } from "./db"; 3 | -------------------------------------------------------------------------------- /tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": [ 4 | "node_modules", 5 | "dist", 6 | "tests", 7 | "scripts" 8 | ] 9 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "commonjs", 5 | "outDir": "./dist", 6 | "strict": true, 7 | "esModuleInterop": true, 8 | "emitDecoratorMetadata": true, 9 | "experimentalDecorators": true, 10 | "allowSyntheticDefaultImports": true, 11 | "sourceMap": false, 12 | "noImplicitReturns": true, 13 | "noImplicitAny": true, 14 | "resolveJsonModule": true, 15 | "downlevelIteration": true, 16 | "declaration": true, 17 | "lib": ["es2015", "es7", "es6", "dom", "es2017"], 18 | "baseUrl": ".", 19 | "types": [ 20 | "node", 21 | "mocha" 22 | ], 23 | "paths": { 24 | "@/*": [ 25 | "src/*" 26 | ] 27 | } 28 | }, 29 | "include": [ 30 | "src/**/*", 31 | "tests/**/*", 32 | "scripts/**/*" 33 | ], 34 | "exclude": [ 35 | "node_modules", 36 | "dist" 37 | ] 38 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bson-lite", 3 | "version": "0.1.0", 4 | "description": "File-based BSON storage, with indexing and joins", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "prepublishOnly": "npm run tsc", 8 | "test": "ts-mocha tests/**/*.spec.ts", 9 | "tsc": "tsc -p tsconfig.lib.json" 10 | }, 11 | "keywords": [ 12 | "bson", 13 | "database", 14 | "flat-file-database" 15 | ], 16 | "author": "Pacharapol Withayasakpunt", 17 | "license": "MIT", 18 | "repository": { 19 | "type": "git", 20 | "url": "https://github.com/patarapolw/rep2recall.git" 21 | }, 22 | "dependencies": { 23 | "@types/bson": "^4.0.0", 24 | "@types/lodash.filter": "^4.6.6", 25 | "@types/lodash.map": "^4.6.13", 26 | "@types/uuid": "^3.4.5", 27 | "bson": "^4.0.2", 28 | "lodash.filter": "^4.6.0", 29 | "lodash.map": "^4.6.0", 30 | "uuid": "^3.3.3" 31 | }, 32 | "publishConfig": { 33 | "access": "public" 34 | }, 35 | "devDependencies": { 36 | "@types/mocha": "^5.2.7" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/db.ts: -------------------------------------------------------------------------------- 1 | import bson from "bson"; 2 | import fs from "fs"; 3 | 4 | export class Db { 5 | public filename: string; 6 | private data: any = {}; 7 | 8 | constructor(filename: string) { 9 | this.filename = filename; 10 | if (fs.existsSync(this.filename)) { 11 | this.data = bson.deserialize(fs.readFileSync(this.filename)); 12 | } 13 | } 14 | 15 | public commit() { 16 | fs.writeFileSync(this.filename, bson.serialize(this.data)); 17 | } 18 | 19 | public get(p: string | string[], defaults?: any) { 20 | return dotGetter(this.data, p, defaults); 21 | } 22 | 23 | public set(p: string | string[], value: any) { 24 | return dotSetter(this.data, p, value); 25 | } 26 | } 27 | 28 | export function dotGetter(data: any, p: string | string[], defaults?: any) { 29 | if (typeof p === "string") { 30 | p = p.split("."); 31 | } 32 | 33 | p.forEach((pn, i) => { 34 | if (Array.isArray(data) && /\d+/.test(pn)) { 35 | data = data[parseInt(pn)]; 36 | } else if (isObjectNotNull(data)) { 37 | data = data[pn]; 38 | } else if (i < p.length - 1) { 39 | data = {}; 40 | } 41 | }); 42 | 43 | if (isObjectNotNull(data) && Object.keys(data).length === 0) { 44 | return defaults; 45 | } 46 | 47 | return data; 48 | } 49 | 50 | export function dotSetter(data: any, p: string | string[], value: any) { 51 | if (typeof p === "string") { 52 | p = p.split("."); 53 | } 54 | 55 | p.slice(0, p.length - 1).forEach((pn, i) => { 56 | if (Array.isArray(data) && /\d+/.test(pn)) { 57 | data = data[parseInt(pn)]; 58 | } else if (isObjectNotNull(data)) { 59 | data = data[pn]; 60 | } else if (i < p.length - 1) { 61 | data = {}; 62 | } 63 | }); 64 | 65 | if (isObjectNotNull(data)) { 66 | data[p[p.length - 1]] = value; 67 | } 68 | } 69 | 70 | export function isObjectNotNull(data: any) { 71 | return data && typeof data === "object" && data.constructor === {}.constructor; 72 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/node,macos 3 | # Edit at https://www.gitignore.io/?templates=node,macos 4 | 5 | ### macOS ### 6 | # General 7 | .DS_Store 8 | .AppleDouble 9 | .LSOverride 10 | 11 | # Icon must end with two \r 12 | Icon 13 | 14 | # Thumbnails 15 | ._* 16 | 17 | # Files that might appear in the root of a volume 18 | .DocumentRevisions-V100 19 | .fseventsd 20 | .Spotlight-V100 21 | .TemporaryItems 22 | .Trashes 23 | .VolumeIcon.icns 24 | .com.apple.timemachine.donotpresent 25 | 26 | # Directories potentially created on remote AFP share 27 | .AppleDB 28 | .AppleDesktop 29 | Network Trash Folder 30 | Temporary Items 31 | .apdisk 32 | 33 | ### Node ### 34 | # Logs 35 | logs 36 | *.log 37 | npm-debug.log* 38 | yarn-debug.log* 39 | yarn-error.log* 40 | lerna-debug.log* 41 | 42 | # Diagnostic reports (https://nodejs.org/api/report.html) 43 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 44 | 45 | # Runtime data 46 | pids 47 | *.pid 48 | *.seed 49 | *.pid.lock 50 | 51 | # Directory for instrumented libs generated by jscoverage/JSCover 52 | lib-cov 53 | 54 | # Coverage directory used by tools like istanbul 55 | coverage 56 | *.lcov 57 | 58 | # nyc test coverage 59 | .nyc_output 60 | 61 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 62 | .grunt 63 | 64 | # Bower dependency directory (https://bower.io/) 65 | bower_components 66 | 67 | # node-waf configuration 68 | .lock-wscript 69 | 70 | # Compiled binary addons (https://nodejs.org/api/addons.html) 71 | build/Release 72 | 73 | # Dependency directories 74 | node_modules/ 75 | jspm_packages/ 76 | 77 | # TypeScript v1 declaration files 78 | typings/ 79 | 80 | # TypeScript cache 81 | *.tsbuildinfo 82 | 83 | # Optional npm cache directory 84 | .npm 85 | 86 | # Optional eslint cache 87 | .eslintcache 88 | 89 | # Optional REPL history 90 | .node_repl_history 91 | 92 | # Output of 'npm pack' 93 | *.tgz 94 | 95 | # Yarn Integrity file 96 | .yarn-integrity 97 | 98 | # dotenv environment variables file 99 | .env 100 | .env.test 101 | 102 | # parcel-bundler cache (https://parceljs.org/) 103 | .cache 104 | 105 | # next.js build output 106 | .next 107 | 108 | # nuxt.js build output 109 | .nuxt 110 | 111 | # vuepress build output 112 | .vuepress/dist 113 | 114 | # Serverless directories 115 | .serverless/ 116 | 117 | # FuseBox cache 118 | .fusebox/ 119 | 120 | # DynamoDB Local files 121 | .dynamodb/ 122 | 123 | # End of https://www.gitignore.io/api/node,macos 124 | 125 | /dist/ 126 | *.r2r 127 | -------------------------------------------------------------------------------- /src/collection.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "events"; 2 | import uuid4 from "uuid/v4"; 3 | import { Db, isObjectNotNull } from "./db"; 4 | import _filter from "lodash.filter"; 5 | import _map from "lodash.map"; 6 | 7 | interface IMeta { 8 | unique: Partial>; 9 | indexes: Partial>>; 10 | } 11 | 12 | interface ICollectionOptions { 13 | unique?: Array; 14 | indexes?: Array; 15 | } 16 | 17 | export class Collection { 18 | private db: Db; 19 | private name: string; 20 | 21 | public events: EventEmitter; 22 | 23 | constructor(db: Db, name: string, options: Partial> = {}) { 24 | this.db = db; 25 | this.name = name; 26 | this.events = new EventEmitter(); 27 | 28 | if (!this.db.get(this.name)) { 29 | this.db.set(this.name, { 30 | __meta: { 31 | unique: (() => { 32 | const output: any = {}; 33 | if (options.unique) { 34 | for (const u of options.unique) { 35 | output[u] = {}; 36 | } 37 | } 38 | 39 | return output; 40 | })(), 41 | indexes: (() => { 42 | const output: any = {}; 43 | if (options.indexes) { 44 | for (const i of options.indexes) { 45 | output[i] = {}; 46 | } 47 | } 48 | 49 | return output; 50 | })() 51 | }, 52 | data: {} 53 | }); 54 | } 55 | } 56 | 57 | get __meta(): IMeta { 58 | return this.db.get(`${this.name}.__meta`); 59 | } 60 | 61 | public create(entry: T): string | null { 62 | this.events.emit("pre-create", entry); 63 | 64 | if (this.isDuplicate(entry)) { 65 | return null; 66 | } 67 | 68 | this.addDuplicate(entry); 69 | 70 | let { _id } = entry as any; 71 | if (!_id) { 72 | _id = uuid4(); 73 | } 74 | 75 | this.db.set(`${this.name}.data.${_id}`, { 76 | _id, 77 | ...entry 78 | }); 79 | 80 | this.addIndex(entry, _id); 81 | 82 | this.events.emit("create", entry); 83 | return _id; 84 | } 85 | 86 | public find(cond: any): T[] { 87 | this.events.emit("pre-read", cond); 88 | let data: T[] | null = []; 89 | 90 | if (isObjectNotNull(cond) && Object.keys(cond).length === 1) { 91 | const k = Object.keys(cond)[0]; 92 | if (k === "_id") { 93 | data = this.getByIndex(cond[k]); 94 | } 95 | const indexes = this.__meta.indexes; 96 | if (Object.keys(indexes).includes(k)) { 97 | data = this.getByIndex(cond[k], k); 98 | } 99 | } 100 | 101 | if (!data) { 102 | data = _filter(Object.values(this.db.get(`${this.name}.data`, {})), cond); 103 | } 104 | 105 | this.events.emit("read", cond, data); 106 | 107 | return data; 108 | } 109 | 110 | public get(cond: any): T | null { 111 | return this.find(cond)[0] || null; 112 | } 113 | 114 | public getByIndex(_id: string, indexName?: string): T[] { 115 | if (indexName) { 116 | const indexes = this.__meta.indexes; 117 | if (!Object.keys(indexes).includes(indexName)) { 118 | throw new Error("Invalid index name"); 119 | } 120 | 121 | return this.getIndex(indexName as any, _id).map((el) => this.getByIndex(el)[0]); 122 | } 123 | 124 | return [this.db.get(`${this.name}.data.${_id}`)] 125 | } 126 | 127 | public update( 128 | cond: any, 129 | transform: any 130 | ): boolean { 131 | this.events.emit("pre-update", cond, transform); 132 | 133 | const found = this.find(cond); 134 | const changes = found.map(transform); 135 | if (changes.some(this.isDuplicate)) { 136 | return false; 137 | } 138 | found.forEach(this.removeDuplicate); 139 | changes.forEach(this.addDuplicate); 140 | 141 | for (const c of changes) { 142 | this.db.set(`${this.name}.data.${(c as any)._id}`, c); 143 | } 144 | this.events.emit("update", cond, transform, changes); 145 | 146 | return true; 147 | } 148 | 149 | public async delete( 150 | cond: any 151 | ) { 152 | this.events.emit("pre-delete", cond); 153 | 154 | const changes = this.find(cond); 155 | changes.forEach(this.removeDuplicate); 156 | 157 | for (const c of changes) { 158 | this.db.set(`${this.name}.data.${(c as any)._id}`, undefined); 159 | this.removeIndex(c, (c as any)._id); 160 | } 161 | 162 | this.events.emit("delete", cond, changes); 163 | } 164 | 165 | private isDuplicate(entry: T): boolean { 166 | for (const [k, v] of Object.entries(this.__meta.unique)) { 167 | if (v[(entry as any)[k]]) { 168 | return true; 169 | } 170 | } 171 | 172 | return false; 173 | } 174 | 175 | private addDuplicate(entry: T) { 176 | Object.keys(this.__meta.unique).map((u) => { 177 | return this.db.set(`${this.name}.__meta.unique.${u}.${(entry as any)[u]}`, true); 178 | }); 179 | } 180 | 181 | private removeDuplicate(entry: T) { 182 | Object.keys(this.__meta.unique).map((u) => { 183 | return this.db.set(`${this.name}.__meta.unique.${u}.${(entry as any)[u]}`, undefined); 184 | }); 185 | } 186 | 187 | private getIndex(k: keyof T, v: string): string[] { 188 | return this.db.get(`${this.name}.__meta.indexes.${k}.${v}`, []); 189 | } 190 | 191 | private addIndex(entry: T, _id: string) { 192 | const indexes = this.__meta.indexes; 193 | for (const [k, v] of Object.entries(entry)) { 194 | if (Object.keys(indexes).includes(k)) { 195 | const ids: string[] = this.getIndex(k as any, v); 196 | if (!ids.includes(_id)) { 197 | ids.push(_id); 198 | this.db.set(`${this.name}.__meta.indexes.${k}.${v}`, ids); 199 | } 200 | } 201 | } 202 | } 203 | 204 | private removeIndex(entry: T, _id: string) { 205 | const indexes = this.__meta.indexes; 206 | for (const [k, v] of Object.entries(entry)) { 207 | if (Object.keys(indexes).includes(k)) { 208 | const ids: string[] = this.getIndex(k as any, v); 209 | ids.splice(ids.indexOf(_id), 1); 210 | this.db.set(`${this.name}.__meta.indexes.${k}.${v}`, ids); 211 | } 212 | } 213 | } 214 | } 215 | 216 | interface IJoiner { 217 | col: T[], 218 | key: keyof T; 219 | null?: boolean; 220 | } 221 | 222 | export function joinCollection( 223 | left: IJoiner, right: IJoiner, 224 | mapFn?: (left?: T, right?: U) => T & U 225 | ): Array { 226 | const joinMap: Record = {}; 230 | 231 | for (const l of left.col) { 232 | let key: any; 233 | if (l[left.key]) { 234 | key = l[left.key]; 235 | } else if (left.null) { 236 | key = uuid4(); 237 | } 238 | 239 | joinMap[key] = joinMap[key] || {}; 240 | joinMap[key].left = l; 241 | } 242 | 243 | for (const r of right.col) { 244 | let key: any; 245 | if (r[right.key]) { 246 | key = r[right.key]; 247 | } else if (right.null) { 248 | key = uuid4(); 249 | } 250 | 251 | joinMap[key] = joinMap[key] || {}; 252 | joinMap[key].right = r; 253 | } 254 | 255 | return Object.values(joinMap) 256 | .filter((el) => el.left || el.right) 257 | .map((el) => { 258 | if (mapFn) { 259 | return mapFn(el.left, el.right); 260 | } else { 261 | return Object.assign(el.right || {}, el.left || {}) as T & U; 262 | } 263 | }); 264 | } --------------------------------------------------------------------------------