├── .eslintignore ├── src ├── index.ts ├── tests │ ├── util │ │ ├── testHelpers.ts │ │ └── TestClasses.ts │ ├── PouchORM.test.ts │ └── PouchCollection.test.ts ├── helpers.ts ├── sandbox │ └── sandbox.ts ├── types.ts ├── PouchORM.ts └── PouchCollection.ts ├── jest.config.js ├── tsconfig.json ├── .eslintrc.json ├── coverage.svg ├── coverage.json ├── LICENSE ├── MIT ├── .npmignore ├── .gitignore ├── .github └── workflows │ └── main.yml ├── package.json └── README.md /.eslintignore: -------------------------------------------------------------------------------- 1 | */.js 2 | node_modules 3 | dist 4 | webpack/*.js 5 | .webpack 6 | out -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './PouchORM'; 2 | export * from './PouchCollection'; 3 | export * from './types'; 4 | export * from './helpers'; 5 | 6 | -------------------------------------------------------------------------------- /src/tests/util/testHelpers.ts: -------------------------------------------------------------------------------- 1 | import { Person } from './TestClasses'; 2 | 3 | 4 | export function makePerson(): Person { 5 | return { 6 | name: 'Spyder', 7 | age: 40, 8 | }; 9 | } 10 | 11 | export async function waitFor(time = 1000) { 12 | await new Promise((r) => setTimeout(r, time)); 13 | } -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "transform": { 3 | "^.+\\.test\\.ts$": "ts-jest" 4 | }, 5 | "testRegex": "^.+\\.test\\.ts$", 6 | "moduleFileExtensions": ["ts", "js", "json", "node"], 7 | "preset": "ts-jest/presets/js-with-ts", 8 | "testEnvironment": "node", 9 | "testMatch": null, 10 | coverageReporters: ["json", "lcov", "text", "clover", "text-summary"] 11 | } 12 | -------------------------------------------------------------------------------- /src/helpers.ts: -------------------------------------------------------------------------------- 1 | import PouchDB from 'pouchdb'; 2 | import PouchFind from 'pouchdb-find'; 3 | import { IModel } from './types'; 4 | 5 | PouchDB.plugin(PouchFind); 6 | 7 | export function getPouchDBWithPlugins() { 8 | return PouchDB; 9 | } 10 | 11 | export function UpsertHelper(item: T) { 12 | return { 13 | merge: (existing: T) => ({...existing, ...item, _rev: existing._rev}), 14 | replace: (existing: T) => ({...item, _rev: existing._rev}) 15 | }; 16 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2018", 4 | "module": "commonjs", 5 | "lib": [ 6 | "es6", 7 | "dom" 8 | ], 9 | "declaration": true, 10 | "sourceMap": true, 11 | "outDir": "dist", 12 | "moduleResolution": "node", 13 | "baseUrl": ".", 14 | "esModuleInterop": true, 15 | "experimentalDecorators": true, 16 | "emitDecoratorMetadata": true 17 | }, 18 | "include": [ 19 | "src/**/*" 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /src/sandbox/sandbox.ts: -------------------------------------------------------------------------------- 1 | import { PouchORM } from '../PouchORM' 2 | import { PersonCollection } from '../tests/util/TestClasses' 3 | 4 | async function main() { 5 | 6 | const personCollection = new PersonCollection('sandbox_temp') 7 | await PouchORM.clearDatabase('sandbox_temp') 8 | 9 | const person = await personCollection.upsert({name: 'Mofe', age: 34}) 10 | const person2 = await personCollection.upsert({name: 'Amy', age: 25}) 11 | 12 | // await personCollection.remove(person) 13 | 14 | console.log('findOne', await personCollection.findOne({_id: person._id})) 15 | console.log('find', await personCollection.find()) 16 | } 17 | 18 | main() -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true, 5 | "node": true, 6 | "jest": true 7 | }, 8 | "extends": [ 9 | "standard", 10 | "prettier" 11 | ], 12 | "parser": "@typescript-eslint/parser", 13 | "parserOptions": { 14 | "ecmaFeatures": { 15 | "jsx": true 16 | }, 17 | "ecmaVersion": 12, 18 | "sourceType": "module" 19 | }, 20 | "plugins": [ 21 | "react", 22 | "prettier", 23 | "@typescript-eslint" 24 | ], 25 | "rules": { 26 | "react/prop-types": "off", 27 | "@typescript-eslint/interface-name-prefix": "off", 28 | "@typescript-eslint/no-var-requires": "off", 29 | "require-atomic-updates": "off", 30 | "no-unused-vars": "off", 31 | "@typescript-eslint/camelcase": "off", 32 | "@typescript-eslint/ban-ts-ignore": "off", 33 | "@typescript-eslint/explicit-function-return-type": "off", 34 | "no-use-before-define": "off" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /coverage.svg: -------------------------------------------------------------------------------- 1 | coverage: 77.72%coverage77.72% -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export interface IModel { 2 | _id?: IDType; 3 | _rev?: string; 4 | _deleted?: boolean; 5 | $timestamp?: number; 6 | $collectionType?: string; // Holds what type of object this is 7 | /** 8 | * Will automatically be filled with PouchORM.userId on upserts 9 | */ 10 | $by?: string; 11 | } 12 | 13 | export enum CollectionState { 14 | NEW, 15 | LOADING, 16 | READY, 17 | } 18 | 19 | export enum ClassValidate { 20 | OFF, 21 | ON, 22 | ON_AND_LOG, 23 | ON_AND_REJECT 24 | } 25 | 26 | export abstract class PouchModel implements IModel { 27 | constructor(item: T) { 28 | Object.assign(this, item); 29 | } 30 | 31 | _id?: IDType; 32 | _rev?: string; 33 | _deleted?: boolean; 34 | $timestamp?: number; 35 | $collectionType?: string; 36 | /** 37 | * Will automatically be filled with PouchORM.userId on upserts 38 | */ 39 | $by?: string; 40 | } 41 | 42 | export type SyncResult = PouchDB.Replication.SyncResult; 43 | export type Sync = PouchDB.Replication.Sync; -------------------------------------------------------------------------------- /coverage.json: -------------------------------------------------------------------------------- 1 | coverage: 79.66%coverage79.66% -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Iyobo Eki 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MIT: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2010-2020 Iyobo Eki. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Node template 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | 10 | # Runtime data 11 | pids 12 | *.pid 13 | *.seed 14 | *.pid.lock 15 | 16 | # Directory for instrumented libs generated by jscoverage/JSCover 17 | lib-cov 18 | 19 | # Coverage directory used by tools like istanbul 20 | coverage 21 | 22 | # nyc test coverage 23 | .nyc_output 24 | 25 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 26 | .grunt 27 | 28 | # Bower dependency directory (https://bower.io/) 29 | bower_components 30 | 31 | # node-waf configuration 32 | .lock-wscript 33 | 34 | # Compiled binary addons (https://nodejs.org/api/addons.html) 35 | build/Release 36 | 37 | # Dependency directories 38 | node_modules/ 39 | jspm_packages/ 40 | 41 | # TypeScript v1 declaration files 42 | typings/ 43 | 44 | # Optional npm cache directory 45 | .npm 46 | 47 | # Optional eslint cache 48 | .eslintcache 49 | 50 | # Optional REPL history 51 | .node_repl_history 52 | 53 | # Output of 'npm pack' 54 | *.tgz 55 | 56 | # Yarn Integrity file 57 | .yarn-integrity 58 | 59 | # dotenv environment variables file 60 | .env 61 | 62 | # next.js build output 63 | .next 64 | 65 | .idea 66 | unit_test* -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Node template 3 | 4 | # IntelliJ 5 | .idea 6 | 7 | # Logs 8 | logs 9 | *.log 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | 14 | # Runtime data 15 | pids 16 | *.pid 17 | *.seed 18 | *.pid.lock 19 | 20 | # Directory for instrumented libs generated by jscoverage/JSCover 21 | lib-cov 22 | 23 | # Coverage directory used by tools like istanbul 24 | coverage 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # TypeScript v1 declaration files 46 | typings/ 47 | 48 | # Optional npm cache directory 49 | .npm 50 | 51 | # Optional eslint cache 52 | .eslintcache 53 | 54 | # Optional REPL history 55 | .node_repl_history 56 | 57 | # Output of 'npm pack' 58 | *.tgz 59 | 60 | # Yarn Integrity file 61 | .yarn-integrity 62 | 63 | # dotenv environment variables file 64 | .env 65 | 66 | # next.js build output 67 | .next 68 | 69 | dist/ 70 | unit_test* 71 | 72 | coverage.txt 73 | badge.json 74 | 75 | temp 76 | *_temp* -------------------------------------------------------------------------------- /src/tests/util/TestClasses.ts: -------------------------------------------------------------------------------- 1 | import { IsNumber, IsString } from 'class-validator'; 2 | import { PouchCollection } from '../../PouchCollection'; 3 | import { IModel, PouchModel } from '../../types'; 4 | 5 | // NOTE: Cannot test PouchORM.sync with memory adapter. Not supported. 6 | // PouchORM.PouchDB.plugin(require('pouchdb-adapter-memory')); 7 | // PouchORM.adapter = 'memory'; 8 | 9 | export interface Person extends IModel { 10 | name: string; 11 | age: number; 12 | otherInfo?: Record; 13 | lastChangedBy?: string; 14 | } 15 | 16 | export class PersonCollection extends PouchCollection { 17 | 18 | // Optional. Overide to define collection-specific indexes. 19 | async beforeInit(): Promise { 20 | 21 | await this.addIndex(['age']); // be sure to create an index for what you plan to filter by. 22 | } 23 | 24 | // Optional. Override to perform actions after all the necessary indexes have been created. 25 | async afterInit(): Promise { 26 | 27 | } 28 | 29 | async onChangeUpserted(item): Promise { 30 | console.log('onChangeUpserted',item) 31 | } 32 | 33 | async onChangeDeleted(item): Promise { 34 | console.log('onChangeDeleted',item) 35 | } 36 | 37 | async onChangeError(error): Promise { 38 | } 39 | } 40 | 41 | 42 | export class Account extends PouchModel { 43 | @IsString() 44 | name: string; 45 | 46 | @IsNumber() 47 | age: number; 48 | } 49 | 50 | export class AccountCollection extends PouchCollection { 51 | async onChangeUpserted(item): Promise { 52 | // console.log(item) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | build: 13 | 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v1 18 | - name: Install and build 19 | run: yarn 20 | - name: Run Tests 21 | run: yarn test:coverage >> coverage.txt 22 | - name: Extract coverage percentage 23 | id: extract_coverage 24 | run: | 25 | COVERAGE=$(awk '/Statements/ {print $3}' coverage.txt | tr -d '%') 26 | echo "COVERAGE=$COVERAGE" >> $GITHUB_ENV 27 | echo "COVERAGE=$COVERAGE" 28 | - name: Create coverage badge 29 | run: | 30 | # Create the JSON payload and encode it for URL 31 | LABEL="coverage" 32 | MESSAGE="${{ env.COVERAGE }}%" 33 | COLOR="brightgreen" 34 | 35 | # Use curl to fetch the badge 36 | curl -G "https://img.shields.io/static/v1" \ 37 | --data-urlencode "label=$LABEL" \ 38 | --data-urlencode "message=$MESSAGE" \ 39 | --data-urlencode "color=$COLOR" \ 40 | -o coverage-badge.svg 41 | 42 | # Print the generated badge to verify 43 | cat coverage-badge.svg 44 | - name: Configure Git for pushing 45 | run: | 46 | git config user.name "GitHub Action" 47 | git config user.email "action@github.com" 48 | git remote set-url origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git 49 | - name: Commit coverage badge 50 | run: | 51 | mv coverage-badge.svg ./coverage.svg 52 | git add . 53 | if ! git diff-index --quiet HEAD; then 54 | git commit -m "Update coverage badge" 55 | fi 56 | - name: Push changes 57 | run: | 58 | # push to master 59 | git push origin HEAD:master 60 | - uses: wow-actions/purge-readme@v1 61 | with: 62 | GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pouchorm", 3 | "version": "4.1.4", 4 | "description": "PouchDB ORM for Typescript. Work with Multiple Pouch databases or multiple collections in a single database. Supports all platforms PouchDB supports.", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "keywords": [ 8 | "pouchdb", 9 | "orm", 10 | "odm", 11 | "Typescript", 12 | "electron", 13 | "web", 14 | "react-native" 15 | ], 16 | "repository": { 17 | "type": "github", 18 | "url": "https://github.com/iyobo/pouchorm" 19 | }, 20 | "scripts": { 21 | "test": "jest", 22 | "test:coverage": "jest --coverage", 23 | "test:watch": "jest --watch", 24 | "build": "tsc", 25 | "prepublish": "tsc && npm run lint && npm test", 26 | "dev": "tsc -w", 27 | "lint": "eslint --fix", 28 | "publish:patch": "npm run prepublish && npm version patch && npm publish", 29 | "publish:minor": "npm run prepublish && npm version minor && npm publish", 30 | "publish:major": "npm run prepublish && npm version major && npm publish" 31 | }, 32 | "files": [ 33 | "dist/**/*" 34 | ], 35 | "author": "Iyobo Eki", 36 | "license": "MIT", 37 | "dependencies": { 38 | "async-retry": "^1.3.1", 39 | "pouchdb-adapter-memory": "^9.0.0", 40 | "pouchdb": "^9.0.0", 41 | "pouchdb-find": "8.0.1", 42 | "reflect-metadata": "^0.1.13", 43 | "uuid": "^8.3.2" 44 | }, 45 | "devDependencies": { 46 | "@types/jest": "^24.0.25", 47 | "@types/lodash": "^4.14.149", 48 | "@types/node": "^12.12.24", 49 | "@types/pouchdb": "^6.4.2", 50 | "@typescript-eslint/eslint-plugin": "^5.9.0", 51 | "@typescript-eslint/parser": "^5.9.0", 52 | "eslint": "7.29.0", 53 | "eslint-config-prettier": "8.3.0", 54 | "eslint-config-standard": "16.0.3", 55 | "eslint-plugin-import": "2.23.4", 56 | "eslint-plugin-node": "11.1.0", 57 | "eslint-plugin-prettier": "3.4.0", 58 | "eslint-plugin-promise": "5.1.0", 59 | "eslint-plugin-react": "7.24.0", 60 | "eslint-plugin-standard": "5.0.0", 61 | "jest": "^29.7.0", 62 | "prettier": "2.3.1", 63 | "supertest": "^4.0.2", 64 | "ts-jest": "^29.2.5", 65 | "typescript": "^5.6.3" 66 | }, 67 | "optionalDependencies": { 68 | "class-validator": "^0.14.1" 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/tests/PouchORM.test.ts: -------------------------------------------------------------------------------- 1 | // Person.ts 2 | const SECONDS = 1000; 3 | jest.setTimeout(70 * SECONDS) 4 | 5 | import {PersonCollection} from './util/TestClasses'; 6 | import {PouchORM} from '../PouchORM'; 7 | import {makePerson} from './util/testHelpers'; 8 | 9 | 10 | describe('PouchORM', () => { 11 | describe('deleteDatabase', () => { 12 | it('works', async () => { 13 | const dbName = 'unit_test_volatile'; 14 | const personCollection: PersonCollection = new PersonCollection(dbName); 15 | 16 | // ensures the database is created and populated 17 | await personCollection.upsert(makePerson()); 18 | await personCollection.upsert(makePerson()); 19 | const ps = await personCollection.find({}); 20 | expect(ps.length === 2); 21 | 22 | // deleting 23 | await PouchORM.deleteDatabase(dbName); 24 | await expect(personCollection.find({})).rejects.toThrow('database is destroyed'); 25 | 26 | }); 27 | }); 28 | 29 | describe('onSyncChange', () => { 30 | const db1 = 'unit_test_sync_A'; 31 | const db2 = 'unit_test_sync_B'; 32 | 33 | beforeEach(async () => { 34 | PouchORM.ensureDatabase(db1, null) 35 | }) 36 | 37 | afterEach(async () => { 38 | await PouchORM.stopSync(db1); 39 | }); 40 | 41 | afterAll(async ()=>{ 42 | await Promise.all([PouchORM.clearDatabase(db1), PouchORM.clearDatabase(db2)]); 43 | }) 44 | 45 | it('syncs between 2 databases', async () => { 46 | 47 | const changeLog = []; 48 | PouchORM.startSync(db1, db2, { 49 | onChange: (change) => { 50 | // console.log('changeDoc', change.change.docs); 51 | 52 | // only log collection PULL operations 53 | if (change.direction === 'push') return; 54 | 55 | change.change.docs?.forEach(it => { 56 | if (it.$collectionType) changeLog.push(change); 57 | }); 58 | 59 | } 60 | }); 61 | 62 | const a: PersonCollection = new PersonCollection(db1); 63 | const b: PersonCollection = new PersonCollection(db2); 64 | 65 | expect(changeLog.length).toBe(0); 66 | 67 | await b.upsert({ 68 | name: 'Second Spyder', 69 | age: 40, 70 | lastChangedBy: 'userB' 71 | }); 72 | await b.upsert({ 73 | name: 'third Spyder', 74 | age: 35, 75 | lastChangedBy: 'userB' 76 | }); 77 | 78 | // wait 1 seconds 79 | await new Promise((r) => setTimeout(r, 5000)); 80 | expect(changeLog.length).toBe(2); 81 | 82 | // local change should not come in as a sync change 83 | await a.upsert({ 84 | name: 'None Spyder', 85 | age: 20, 86 | lastChangedBy: 'userA' 87 | }); 88 | 89 | // wait 1 seconds 90 | await new Promise((r) => setTimeout(r, 1000)); 91 | expect(changeLog.length).toBe(2); // no change 92 | 93 | }); 94 | }); 95 | 96 | }); -------------------------------------------------------------------------------- /src/PouchORM.ts: -------------------------------------------------------------------------------- 1 | import { PouchCollection } from './PouchCollection'; 2 | import { ClassValidate, IModel, Sync } from './types'; 3 | import ClassValidator from 'class-validator'; 4 | import { getPouchDBWithPlugins } from './helpers'; 5 | 6 | const PouchDB = getPouchDBWithPlugins(); 7 | 8 | export type ORMSyncOptions = { 9 | opts?: PouchDB.Configuration.DatabaseConfiguration, 10 | onChange?: (change: PouchDB.Replication.SyncResult) => unknown 11 | onPaused?: (info: unknown) => unknown 12 | onError?: (error: unknown) => unknown 13 | }; 14 | 15 | export class PouchORM { 16 | private static databases: Record, 19 | collectionInstances: Set> 20 | }> = {}; 21 | static LOGGING = false; 22 | static VALIDATE = ClassValidate.OFF; 23 | static ClassValidator: typeof ClassValidator; 24 | static PouchDB = PouchDB; 25 | 26 | /** 27 | * Set this to enable user change logging with this id for each upsert 28 | */ 29 | static userId: string; 30 | 31 | static adapter: string; 32 | 33 | /** 34 | Prepares the given collection for the given database. 35 | */ 36 | static ensureDatabase(dbName: string, pouchCollection: PouchCollection, opts?: PouchDB.Configuration.DatabaseConfiguration): PouchDB.Database { 37 | 38 | // ensure the database exists 39 | if (!PouchORM.databases[dbName]) { 40 | if (PouchORM.LOGGING) console.log('PouchORM Registering DB: ', dbName); 41 | 42 | // Creates or loads the DB 43 | const db = new PouchDB(dbName, {adapter: PouchORM.adapter, ...opts}); 44 | PouchORM.databases[dbName] = {db, changeListener: undefined, collectionInstances: new Set()}; 45 | } 46 | 47 | // Ensure the asking collection is related to this DB 48 | PouchORM.databases[dbName].collectionInstances.add(pouchCollection); 49 | 50 | // If there is no change listener for the DB, start one. 51 | // This will make it so all related collections get informed when the db changes. 52 | PouchORM.beginChangeListener(dbName) 53 | 54 | return PouchORM.databases[dbName].db; 55 | } 56 | 57 | private static createChangeListener(dbName: string) { 58 | const db = PouchORM.databases[dbName].db; 59 | if (!db) throw new Error(`Cannot create changeListener for non-existent DB: '${dbName}'`); 60 | 61 | return db.changes({ 62 | live: true, 63 | since: 'now', 64 | include_docs: true, 65 | }).on('change', function (change) { 66 | 67 | PouchORM.databases[dbName].collectionInstances.forEach(collectionInstance => { 68 | if(!collectionInstance || change.doc.$collectionType !== collectionInstance.collectionTypeName) return 69 | 70 | if (change.deleted) { 71 | void collectionInstance.onChangeDeleted(change.doc); 72 | } else { 73 | void collectionInstance.onChangeUpserted(change.doc); 74 | } 75 | }); 76 | 77 | }).on('error', function (error) { 78 | console.error(`Change listener error for db "${dbName}"`, error); 79 | 80 | PouchORM.databases[dbName].collectionInstances.forEach(collectionInstance => { 81 | void collectionInstance.onChangeError(error); 82 | }); 83 | }); 84 | } 85 | 86 | /** 87 | If there is no change listener for the DB, start one. 88 | This will make it so all related collections get informed when the db changes. 89 | */ 90 | static beginChangeListener(dbName: string) { 91 | if (!PouchORM.databases[dbName].changeListener) { 92 | PouchORM.databases[dbName].changeListener = PouchORM.createChangeListener(dbName); 93 | } 94 | } 95 | 96 | /** 97 | Stop user oplog handlers for the database 98 | */ 99 | public static stopChangeListener(dbName: string) { 100 | PouchORM.databases[dbName].changeListener?.cancel() 101 | PouchORM.databases[dbName].changeListener = undefined 102 | } 103 | 104 | /** 105 | PouchORM can help you do some basic audit logging by passing in a userId to attach to all changes that originate from this instance. 106 | */ 107 | public static setUser(userId: string) { 108 | PouchORM.userId = userId; 109 | } 110 | 111 | /** 112 | * A map of active sync operations between databases 113 | * from -> to -> SyncOp reference 114 | */ 115 | public static activeSyncOperations: Record>> = {}; 116 | 117 | /** 118 | * start Synchronizing between 2 Databases. Can be files or urls to remote databases. 119 | */ 120 | static startSync(fromDB: string, toDB: string, options: ORMSyncOptions = {}) { 121 | 122 | PouchORM.activeSyncOperations[fromDB] = PouchORM.activeSyncOperations[fromDB] || {}; 123 | if (PouchORM.activeSyncOperations[fromDB][toDB]) { 124 | // stop any previous syncs of same names/paths 125 | PouchORM.activeSyncOperations[fromDB][toDB].cancel(); 126 | } 127 | 128 | const localDb = PouchORM.databases[fromDB]?.db; 129 | if (!localDb) throw new Error(`sourceDB does not exist: ${fromDB}`); 130 | 131 | const remoteDB = new PouchDB(toDB); 132 | 133 | const realOps = { 134 | live: true, 135 | retry: true, 136 | ...options.opts || {} 137 | }; 138 | 139 | // create new sync operation 140 | const syncOperation = localDb.sync(remoteDB, realOps) 141 | .on('change', function (change: PouchDB.Replication.SyncResult) { 142 | // yo, something changed! 143 | if (PouchORM.LOGGING) console.log('PouchORM Pulled new change: ', change); 144 | options.onChange?.(change); 145 | }) 146 | .on('paused', function (info) { 147 | // replication was paused, usually because of a lost connection 148 | options.onPaused?.(info); 149 | }) 150 | .on('error', function (err) { 151 | // totally unhandled error (shouldn't happen) 152 | options.onError?.(err); 153 | }); 154 | 155 | // register new sync operation 156 | PouchORM.activeSyncOperations[fromDB][toDB] = syncOperation; 157 | } 158 | 159 | /** 160 | * Stop one or all sync operations from a db by name. 161 | * @param fromDB 162 | * @param toDB - if no destination DB specified, stop all sync ops for DB. 163 | */ 164 | static stopSync(fromDB: string, toDB?: string) { 165 | if (toDB) { 166 | // close connection to that db 167 | PouchORM.activeSyncOperations[fromDB]?.[toDB]?.cancel(); 168 | } else { 169 | // close all connections 170 | Object.values(PouchORM.activeSyncOperations[fromDB] || {}).forEach(it => it.cancel()); 171 | } 172 | } 173 | 174 | /** 175 | * deletes everything in a database 176 | * @param dbName 177 | */ 178 | static async clearDatabase(dbName: string) { 179 | 180 | const db = PouchORM.databases[dbName]?.db; 181 | if (!db) throw new Error(`Database does not exist: ${dbName}`); 182 | 183 | const result = await db.allDocs(); 184 | const deletedDocs = result.rows.map(row => { 185 | return {_id: row.id, _rev: row.value.rev, _deleted: true}; 186 | }); 187 | return await db.bulkDocs(deletedDocs); 188 | 189 | // Leave as comment for debug 190 | // return Promise.all(result.rows.map(function (row) { 191 | // return db.remove(row.id, row.value.rev); 192 | // })); 193 | } 194 | 195 | static async deleteDatabase(dbName: string) { 196 | 197 | const dbSet = PouchORM.databases[dbName]; 198 | if (!dbSet) throw new Error(`Database does not exist: ${dbName}`); 199 | 200 | // First stop DB change listener 201 | dbSet.changeListener.cancel(); 202 | 203 | // then stop any active syncs (be it remote or local) 204 | if (PouchORM.activeSyncOperations[dbName]) { 205 | const syncs = Object.values(PouchORM.activeSyncOperations[dbName]); 206 | syncs.forEach(it => it.cancel()); 207 | delete PouchORM.activeSyncOperations[dbName]; 208 | } 209 | 210 | // then destroy the DB 211 | const res = await dbSet.db.destroy(); 212 | 213 | // lastly, unregister db from PouchORM 214 | delete PouchORM.databases[dbName]; 215 | 216 | return res; 217 | } 218 | 219 | static getClassValidator() { 220 | let classValidator: typeof ClassValidator; 221 | 222 | try { 223 | classValidator = require('class-validator'); 224 | } catch (error) { 225 | console.log('Error initializing validator: ', error); 226 | } 227 | 228 | return PouchORM.ClassValidator = classValidator; 229 | } 230 | 231 | } 232 | -------------------------------------------------------------------------------- /src/PouchCollection.ts: -------------------------------------------------------------------------------- 1 | import {ClassValidate, CollectionState, IModel} from './types'; 2 | import {UpsertHelper} from './helpers'; 3 | import {v4 as uuid} from 'uuid'; 4 | import {PouchORM} from './PouchORM'; 5 | import CreateIndexResponse = PouchDB.Find.CreateIndexResponse; 6 | 7 | 8 | const retry = require('async-retry'); 9 | 10 | export abstract class PouchCollection, IDType extends string = string> { 11 | 12 | _state = CollectionState.NEW; 13 | db: PouchDB.Database; 14 | collectionTypeName: string; 15 | validate: ClassValidate; 16 | 17 | _indexes: { fields: (keyof T)[]; name?: string, indexId: string}[] = []; 18 | 19 | // Define this static function to generate your own ids. 20 | idGenerator: (item?: T) => IDType | Promise; 21 | 22 | 23 | constructor(dbname: string, opts?: PouchDB.Configuration.DatabaseConfiguration, validate: ClassValidate = ClassValidate.OFF) { 24 | this.db = PouchORM.ensureDatabase(dbname, this, opts); 25 | this.collectionTypeName = this.constructor.name; 26 | this.validate = validate; 27 | if (PouchORM.LOGGING) console.log('initializing collection :', this.collectionTypeName); 28 | } 29 | 30 | async checkInit(): Promise { 31 | if (this._state === CollectionState.READY) return; 32 | if (this._state === CollectionState.NEW) return this.runInit(); 33 | if (this._state === CollectionState.LOADING) { 34 | 35 | // The most probable way we arrive here is if a previous attemot to init collection failed. 36 | // We should wait for init's retries. Honestly this is an extreme case... 37 | return retry(async bail => { 38 | // if anything throws, we retry 39 | if (PouchORM.LOGGING) console.log(`PouchORM waiting for initialization of ${this.constructor.name}...`); 40 | if (this._state === CollectionState.READY) 41 | throw new Error(`PouchCollection: Cannot perform operations on uninitialized collection ${this.constructor.name}`); 42 | 43 | }, { 44 | retries: 3, 45 | minTimeout: 2000 46 | }); 47 | } 48 | } 49 | 50 | /** 51 | * Can be overriden by sub classes to do things before collection initialization. 52 | * E.g define indexes using this.addIndex etc when initializing the collection 53 | */ 54 | async beforeInit(): Promise { 55 | 56 | } 57 | 58 | /** 59 | * Can be overriden by sub classes to perform actions after initialization. 60 | */ 61 | async afterInit(): Promise { 62 | 63 | } 64 | 65 | /** 66 | * Does the actual work to initialize a collection. 67 | */ 68 | private async runInit(): Promise { 69 | this._state = CollectionState.LOADING; 70 | 71 | await this.beforeInit(); 72 | 73 | // await retry(async bail => { 74 | // // if anything throws, we retry 75 | // console.log(`Initializing ${this.constructor.name}...`); 76 | // 77 | // }, { 78 | // retries: 3 79 | // }); 80 | 81 | await this.addIndex([]); // for only collection type 82 | await this.addIndex(['$timestamp']); // for collectionType and timestamp 83 | 84 | await this.afterInit(); 85 | this._state = CollectionState.READY; 86 | } 87 | 88 | /** 89 | * Creates an index. It is highly recommended to Provide a name to reference it later e.g if you ever need to delete it. 90 | * @param {(keyof T)[]} fields 91 | * @param {string} name 92 | * @return {Promise>} 93 | */ 94 | async addIndex(fields: (keyof T)[], name?: string): Promise> { 95 | 96 | // append $collectionType to fields 97 | fields.unshift('$collectionType'); 98 | const res = await this.db.createIndex({ 99 | index: { 100 | fields: fields as string[], 101 | name 102 | }, 103 | }); 104 | 105 | this._indexes.push({fields, name, indexId: (res as unknown as any)?.id }); 106 | 107 | return res 108 | } 109 | 110 | async removeIndex(name: string){ 111 | const idx = this._indexes.findIndex(it=> it.name === name) 112 | if(idx > -1) { 113 | await this.db.deleteIndex({ 114 | ddoc: this._indexes[idx].indexId, 115 | name 116 | }) 117 | 118 | this._indexes.splice(idx, 1); 119 | } 120 | } 121 | 122 | async find( 123 | selector?: Partial | Record, 124 | opts?: { sort?: string[], limit?: number }, 125 | ): Promise { 126 | const sel = selector || {}; 127 | await this.checkInit(); 128 | sel.$collectionType = this.collectionTypeName; 129 | 130 | const {docs} = await this.db.find({ 131 | selector: sel, 132 | sort: opts?.sort || undefined,// FIXME: ensure this works 133 | limit: opts?.limit 134 | }); 135 | 136 | return docs as T[]; 137 | } 138 | 139 | async findOne(selector: Partial | Record): Promise { 140 | const matches = await this.find(selector, {limit: 1}); 141 | return matches.length > 0 ? matches[0] : null; 142 | } 143 | 144 | async findOrFail( 145 | selector?: Partial | Record, 146 | opts?: { sort?: string[], limit?: number }, 147 | ): Promise { 148 | const docs = await this.find(selector, opts); 149 | 150 | if (!Array.isArray(docs) || docs.length === 0) { 151 | throw new Error(`${this.constructor.name} of criteria ${selector} does not exist`); 152 | } 153 | 154 | return docs; 155 | } 156 | 157 | async findOneOrFail(selector: Partial | Record): Promise { 158 | const matches = await this.findOrFail(selector, {limit: 1}); 159 | return matches[0]; 160 | } 161 | 162 | async findById(_id: IDType): Promise { 163 | if (!_id) return null; 164 | return this.findOne({_id} as Partial); 165 | } 166 | 167 | async findByIdOrFail(_id: IDType): Promise { 168 | return this.findOneOrFail({_id} as Partial); 169 | } 170 | 171 | async removeById(id: IDType): Promise { 172 | 173 | const doc: T = await this.findById(id); 174 | if (PouchORM.LOGGING) console.log(this.constructor.name + ' PouchORM removeById', doc); 175 | if (doc) await this.db.remove(doc._id, doc._rev); 176 | } 177 | 178 | async remove(item: T): Promise { 179 | 180 | if (PouchORM.LOGGING) console.log(this.constructor.name + ' PouchORM remove', item); 181 | if (item) await this.db.remove(item._id, item._rev); 182 | } 183 | 184 | private setMetaFields = async (item: T) => { 185 | if (!item._id) { 186 | item._id = (await this.idGenerator?.(item)) || uuid(); 187 | } 188 | 189 | item.$timestamp = Date.now(); 190 | item.$collectionType = this.collectionTypeName; 191 | item.$by = PouchORM.userId || '...'; 192 | 193 | return item; 194 | }; 195 | 196 | private markDeleted = (item: T) => { 197 | item._deleted = true; 198 | 199 | return item; 200 | }; 201 | 202 | async upsert(item: T, deltaFunc?: (existing: T) => T): Promise { 203 | const existing = await this.findById(item._id); 204 | const validate = this.validate !== ClassValidate.OFF 205 | ? this.validate 206 | : PouchORM.VALIDATE !== ClassValidate.OFF 207 | ? PouchORM.VALIDATE 208 | : ClassValidate.OFF; 209 | 210 | if (existing) { 211 | if (!deltaFunc) deltaFunc = UpsertHelper(item).replace; 212 | 213 | item = deltaFunc(existing); 214 | 215 | if (PouchORM.LOGGING) console.log(this.constructor.name + ' PouchORM updating', item); 216 | } else { 217 | if (PouchORM.LOGGING) console.log(this.constructor.name + ' PouchORM create', item); 218 | } 219 | 220 | if (validate !== ClassValidate.OFF && PouchORM.ClassValidator === undefined) PouchORM.getClassValidator(); 221 | 222 | switch (validate) { 223 | case ClassValidate.ON: 224 | if (PouchORM.LOGGING) 225 | console.log('Valid ' + item.constructor.name + ':', await PouchORM.ClassValidator.validate(item)); 226 | break; 227 | case ClassValidate.ON_AND_LOG: 228 | console.log('Valid ' + item.constructor.name + ':', await PouchORM.ClassValidator.validate(item)); 229 | break; 230 | case ClassValidate.ON_AND_REJECT: 231 | await PouchORM.ClassValidator.validateOrReject(item); 232 | break; 233 | } 234 | 235 | await this.setMetaFields(item); 236 | 237 | if (PouchORM.LOGGING) console.log(this.constructor.name + ' PouchORM beforeSave', item); 238 | 239 | await this.db.put(item, {force: true}); 240 | 241 | const doc = await this.findById(item._id); 242 | if (PouchORM.LOGGING) console.log(this.constructor.name + ' PouchORM afterSave', doc); 243 | return doc; 244 | } 245 | 246 | async bulkUpsert(items: T[]): Promise> { 247 | const itemsWithMeta = await Promise.all(items.map(this.setMetaFields)); 248 | const result = await this.db.bulkDocs(itemsWithMeta); 249 | return result; 250 | } 251 | 252 | async bulkRemove(items: T[]): Promise> { 253 | 254 | const result = await this.db.bulkDocs(items.map(this.markDeleted)); 255 | return result; 256 | } 257 | 258 | async onChangeUpserted(item: T) { 259 | 260 | } 261 | 262 | async onChangeDeleted(item: T) { 263 | 264 | } 265 | 266 | async onChangeError(error: Error) { 267 | 268 | } 269 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PouchORM 2 | 3 | [![CI](https://github.com/iyobo/pouchorm/actions/workflows/main.yml/badge.svg?cacheBuster=1)](https://github.com/iyobo/pouchorm/actions/workflows/main.yml?cacheBuster=1) 4 | 5 | 6 | 7 | 8 | The definitive ORM for working with PouchDB. 9 | 10 | The Pouch/Couch database ecosystem is a great choice for client-side products that need the complex 11 | (and seemingly oxymoronic) sibling-features of Offline-First **and** Realtime collaboration. 12 | 13 | But the base pouchDB interface is rather bare and oft-times painful to work with. That's where PouchORM comes in. 14 | 15 | PouchORM does a lot of the heavy lifting for you and makes it easy to get going with PouchDB so 16 | you can focus on your data... not the database. 17 | 18 | ## Highlights 19 | - Typescript is a first class citizen. 20 | - Will work with raw javascript, but you'll be missing out on the cool Typescript dev perks. 21 | - Introduces the concept of *Collections* to pouchdb 22 | - Multiple collections in a single Database 23 | - Multiple collections in multiple Databases 24 | - Supports web, electron, react-native, and anything else pouchdb supports. 25 | - Supports optional class validation 26 | 27 | 28 | ## To install 29 | `npm i pouchorm` 30 | 31 | or if you prefer yarn: 32 | `yarn add pouchorm` 33 | 34 | When using the optional class validation, also install `class-validator` as a dependency of your project using `npm` or `yarn`. 35 | 36 | ## Changelog 37 | - v2.0.2 38 | - Added optional ID generics i.e PouchCollection, IModel, and PouchModel 39 | - v2.0.0 40 | - feat: changed meta name `$updatedBy` to simply `$by` to conserve space. 41 | - v1.6.0 42 | - feat: Added simplified audit trace, specified by `PouchORM.setUserId(...)`. 43 | - v1.5.0 44 | - feat: Added ORM support for managing syncing between multiple databases 45 | - v1.3 46 | - feat: Added Delta sync support 47 | 48 | ## How to Use 49 | 50 | Consider this definition of a model and it's collection. 51 | ```typescript 52 | // Person.ts 53 | 54 | import {IModel, PouchCollection, PouchORM} from "pouchorm"; 55 | PouchORM.LOGGING = true; // enable diagnostic logging if desired 56 | 57 | export interface IPerson extends IModel { 58 | name: string; 59 | age: number; 60 | otherInfo: Record; 61 | } 62 | 63 | export class PersonCollection extends PouchCollection { 64 | 65 | // Optional. Override to define collection-specific indexes. 66 | async beforeInit(): Promise { 67 | 68 | await this.addIndex(['age']); // be sure to create an index for what you plan to filter by. 69 | } 70 | 71 | // Optional. Overide to perform actions after all the necessary indexes have been created. 72 | async afterInit(): Promise { 73 | 74 | } 75 | 76 | } 77 | 78 | ``` 79 | 80 | `IModel` contains the meta fields needed by PouchDB and PouchORM to operate so every model interface definition 81 | needs to extend it. Only supports the same field types as pouchDB does. 82 | 83 | `PouchCollection` is a generic abstract class that should be given your model type. 84 | This helps it guide you later and give you suggestions of how to work with your model. 85 | 86 | In the case that you want the syntactic sugar of classing your models, or you want to use class validation, 87 | `PouchModel` is a generic class implementation of `IModel` that can be extended. 88 | ```typescript 89 | export class Person extends PouchModel { 90 | @IsString() 91 | name: string 92 | 93 | @IsNumber() 94 | age: number 95 | 96 | otherInfo: { [key: string]: any }; 97 | } 98 | 99 | export class PersonCollection extends PouchCollection { 100 | ... 101 | ``` 102 | 103 | If you need to do things before and after initialization, you can override the async hook functions: `beforeInit` 104 | or `afterInit`; 105 | 106 | Now that we have defined our **Model** and a **Collection** for that model, Here is how we instantiate collections. 107 | You should probably define and export collection instances somewhere in your codebase that you can easily import 108 | anywhere in your app. 109 | 110 | ```typescript 111 | 112 | // instantiate a collection by giving it the dbname it should use 113 | export const personCollection: PersonCollection = new PersonCollection('db1'); 114 | 115 | // Another collection. Notice how it shares the same dbname we passed into the previous collection instance. 116 | export const someOtherCollection: SomeOtherCollection = new SomeOtherCollection('db1'); 117 | 118 | // In case we needed the same model but for a different database 119 | export const personCollection2: PersonCollection = new PersonCollection('db2'); 120 | 121 | ``` 122 | 123 | From this point: 124 | - We have our definitions 125 | - We have our collection instances 126 | 127 | We are ready to start CRUDing! 128 | 129 | ```typescript 130 | import {personCollection} from '...' 131 | 132 | // Using collections 133 | let somePerson: IPerson = { 134 | name: 'Basket Mouth', 135 | age: 99, 136 | } 137 | let anotherPerson: IPerson = { 138 | name: 'Bovi', 139 | age: 45, 140 | } 141 | 142 | somePerson = await personCollection.upsert(somePerson); 143 | anotherPerson = await personCollection.upsert(anotherPerson); 144 | 145 | // somePerson has been persisted and will now also have some metafields like _id, _rev, etc. 146 | 147 | somePerson.age = 45; 148 | somePerson = await personCollection.upsert(somePerson); 149 | 150 | // changes to somePerson has been persisted. _rev would have also changed. 151 | 152 | const result: IPerson[] = await personCollection.find({age: 45}) 153 | 154 | // result.length === 2 155 | 156 | ``` 157 | 158 | ## PouchCollection instance API reference 159 | Consider that `T` is the provided type or class definition of your model. 160 | 161 | ### Constructor 162 | `new Collection(dbname: string, opts?: PouchDB.Configuration.DatabaseConfiguration, validate: ClassValidate = ClassValidate.OFF)` 163 | 164 | ### Methods 165 | - `find(criteria: Partial): Promise` 166 | - `findOrFail(criteria: Partial): Promise` 167 | - `findOne(criteria: Partial): Promise` 168 | - `findOneOrFail(criteria: Partial): Promise` 169 | - `findById(_id: string): Promise` 170 | - `findByIdOrFail(_id: string): Promise` 171 | 172 | - `removeById(id: string): Promise` 173 | - `remove(item: T): Promise` 174 | 175 | - `upsert(item: T, deltaFunc?: (existing: T) => T): Promise` 176 | 177 | - `bulkUpsert(items: T[]): Promise<(Response|Error)[]>` 178 | - `bulkRemove(items: T[]): Promise<(Response|Error)[]>` 179 | 180 | ## Class Validation 181 | Class validation brings the power of strong typing and data validation to PouchDB. 182 | 183 | The validation uses the `class-validator` library, and should work anywhere that PouchDB works. This can 184 | be turned on at the global PouchORM level using `PouchORM.VALIDATE` or at the collection level when creating 185 | a new instance of PouchCollection. 186 | 187 | By default, `upsert` calls `PouchORM.getClassValidator()` when validation is turned on. This dynamically 188 | imports to `PouchORM.ClassValidator` with the full instance of the required library. The method can also be 189 | called at any time so that class validation methods, decorators, and so on may used your application without 190 | the need to statically import the library. **However**, if `class-validator` has not been installed to 191 | `node_modules`, this **will** crash PouchORM when `PouchORM.getClassValidator()` is called and/or you attempt 192 | to use `PouchORM.ClassValidator`. 193 | 194 | For complete details and advanced usage of `class-validator`, see their [documentation](https://github.com/typestack/class-validator). 195 | 196 | ## PouchORM metadata 197 | 198 | PouchORM adds some metadata fields to each documents to make certain features possible. 199 | Key of which are `$timestamp` and `$collectionType`. 200 | 201 | ### $timestamp 202 | 203 | This gets updated with a unix timestamp upon upserting a document. This is also auto-indexed for time-sensitive ordering 204 | (i.e so items don't show up in random locations in results each time, which can be disconcerting) 205 | 206 | ### $collectionType 207 | 208 | There is no concept of tables or collections in PouchDB. Only databases. This field helps us differentiate what 209 | collection each document belongs to. This is also auto-indexed for your convenience. 210 | 211 | ### $by (v1.6.x) 212 | 213 | PouchORM can help you append a userId to each originating change to specify who changed a document last. 214 | Simply use `PouchORM.setUserId(...)` to specify who the local/active user is, and PouchORM will put that id here. 215 | If this is not set, this field will be `...` 216 | 217 | If you need more stringent audit log capabilities, that's something you should implement for your application. 218 | 219 | ## Custom ID generation 220 | 221 | You can control the way IDs are generated for new items. Just define the `idGenerator` function property in a 222 | collection object. This can be a normal or async function that returns a string. 223 | 224 | ```typescript 225 | import {personCollection} from '...' 226 | 227 | 228 | personCollection.idGenerator = (item) => { 229 | return 'randomIdString'; 230 | }; 231 | 232 | const p = await personCollection.upsert({...}) 233 | p._id === 'randomIdString' // true 234 | 235 | ``` 236 | 237 | You can also do: 238 | 239 | ```typescript 240 | personCollection.idGenerator = async (item) => { 241 | const anotherString = await someAsyncIDStringBuilder() 242 | return anotherString; 243 | }; 244 | 245 | // or better yet, cleanly override the property in the class for consistency 246 | 247 | export class PersonCollection extends PouchCollection { 248 | 249 | // override 250 | async idGenerator(){ 251 | return 'randomIdString'; 252 | } 253 | } 254 | 255 | ``` 256 | 257 | ## Installing PouchDB plugins 258 | 259 | You can access the base PouchDB module used by PouchORM with `PouchORM.PouchDB`. You can install plugins you need with 260 | that e.g `PouchORM.PouchDB.plugin(...)`. PouchORM already comes with the plugin `pouchdb-find` which is essential for 261 | any useful querying of the database. 262 | 263 | ## Accessing the raw pouchdb database 264 | 265 | Every instance has a reference to the internally instantiated db `collectionInstance.db` that you can use to reference 266 | other methods of the raw pouch db instance e.g `personCollection.db.putAttachment(...)`. 267 | 268 | You can use this for anything that does not directly involve accessing documents e.g adding an attachment is fine. 269 | But caution must be followed when you want to use this to manipulate a document directly, as pouch orm marks documents with 270 | helpful metadata it uses to enhance your development experience, particularly $timestamp and $collectionType. 271 | 272 | It is generally better to rely on the exposed functions in your collection instance. 273 | 274 | If you want more pouchdb feature support, feel free to open an issue. This library is also very simple 275 | to grok, so feel free to send in a PR! 276 | 277 | ## Deleting the Database 278 | 279 | ``` 280 | import {PouchORM} from 'pouchorm' 281 | ... 282 | PouchORM.deleteDatabase(dbName: string) 283 | ``` 284 | It goes without saying that this cannot be undone, so be careful with this! 285 | Also, any loaded `PouchCollection` instances you still have will now throw the error "database is destroyed" if you try to run any DB access operations on them. 286 | 287 | ## Realtime Sync! 288 | 289 | Last but not least, PouchDB is all about sync. 290 | You could always access the native Pouch DB object and run sync operations. 291 | 292 | But as of v1.5, some sugar has been added to make this a simplified PouchORM experience as well. 293 | 294 | Introducing `PouchORM.startSync(fromPath, toPath, opts)` where either paths could 295 | be local paths/names or a remote db url path. Within `opts`, you can specify callbacks that trigger upon specific events 296 | during the realtime sync e.g `onChange`, `onError`,`onStart`, etc. Have a look at the reference. 297 | 298 | You can also cancel real-time sync by `PouchORM.stopSync(fromPath, toPath?)`. If the second parameter is null, it will stop all sync ops for that db regardless of destination. 299 | 300 | ## Supporting the Project 301 | If you use PouchORM and it's helping you do awesome stuff, be a sport and Buy Me A Coffee or Become a Patron!. PRs are also welcome. 302 | NOTE: Tests required for new PR acceptance. Those are easy to make as well. 303 | 304 | # Contributors 305 | 306 | - Iyobo Eki 307 | - Aaron Huggins 308 | -------------------------------------------------------------------------------- /src/tests/PouchCollection.test.ts: -------------------------------------------------------------------------------- 1 | // Person.ts 2 | 3 | import { Account, AccountCollection, Person, PersonCollection } from './util/TestClasses'; 4 | import { ValidationError } from 'class-validator'; 5 | import { ClassValidate } from '../types'; 6 | import { UpsertHelper } from '../helpers'; 7 | import { PouchORM } from '../PouchORM'; 8 | import { makePerson, waitFor } from './util/testHelpers'; 9 | 10 | const dbName = 'unit_test'; 11 | 12 | describe('PouchCollection Instance', () => { 13 | 14 | const personCollection: PersonCollection = new PersonCollection(dbName); 15 | const accountCollection: AccountCollection = new AccountCollection(dbName); 16 | 17 | afterEach(async () => { 18 | jest.clearAllMocks(); 19 | jest.resetAllMocks(); 20 | }); 21 | 22 | describe('upsert', () => { 23 | it('creates new documents if does not exist', async () => { 24 | 25 | const p = makePerson(); 26 | expect(p._id).toBeUndefined(); 27 | expect(p._rev).toBeUndefined(); 28 | 29 | const person: Person = await personCollection.upsert(p) as Person; 30 | 31 | expect(person).toBeTruthy(); 32 | expect(person.name).toBe(makePerson().name); 33 | expect(person._id).toBeTruthy(); 34 | expect(person._rev).toBeTruthy(); 35 | expect(person.$collectionType).toBeTruthy(); 36 | expect(person.$timestamp).toBeTruthy(); 37 | }); 38 | it('updates documents if exist', async () => { 39 | 40 | const p = makePerson(); 41 | const person = await personCollection.upsert(p); 42 | expect(person.age).toBe(p.age); 43 | 44 | person.age = 501; 45 | const updatedPerson = await personCollection.upsert(person); 46 | expect(updatedPerson.age).toBe(501); 47 | }); 48 | it('updates documents with delta function', async () => { 49 | 50 | const p = makePerson(); 51 | const person = await personCollection.upsert(p); 52 | expect(person.age).toBe(p.age); 53 | 54 | person.age = 70; 55 | const updatedPerson = await personCollection.upsert(person, UpsertHelper(person).merge); 56 | expect(updatedPerson.age).toBe(70); 57 | }); 58 | it('uses custom idGenerator if defined when creating documents ', async () => { 59 | 60 | const p = makePerson(); 61 | expect(p._id).toBeUndefined(); 62 | 63 | const randomId = `p${Date.now()}`; 64 | personCollection.idGenerator = () => { 65 | return randomId; 66 | }; 67 | const person: Person = await personCollection.upsert(p) as Person; 68 | 69 | 70 | expect(person).toBeTruthy(); 71 | expect(person._id).toBe(randomId); 72 | 73 | // clean up 74 | personCollection.idGenerator = null; 75 | }); 76 | 77 | it('calls onChangeUpserted when new', async () => { 78 | const a = new Account({ 79 | name: 'Alie', 80 | age: 17 81 | }); 82 | const internalMethodSpy = jest.spyOn(accountCollection, 'onChangeUpserted'); 83 | const account = await accountCollection.upsert(a); 84 | 85 | await waitFor() 86 | expect(internalMethodSpy).toHaveBeenNthCalledWith(1, expect.objectContaining({ 87 | name: 'Alie', 88 | age: 17 89 | })); 90 | 91 | }); 92 | 93 | it('calls onChangeUpserted when updating', async () => { 94 | const a = new Account({ 95 | _id: "alie123", 96 | name: 'Alie', 97 | age: 17 98 | }); 99 | const internalMethodSpy = jest.spyOn(accountCollection, 'onChangeUpserted'); 100 | const persistedA = await accountCollection.upsert(a); 101 | persistedA.age++; 102 | await accountCollection.upsert(persistedA); 103 | 104 | await waitFor() 105 | expect(internalMethodSpy).toHaveBeenNthCalledWith(1, expect.objectContaining({ 106 | name: 'Alie', 107 | age: 17 108 | })); 109 | 110 | expect(internalMethodSpy).toHaveBeenNthCalledWith(2, expect.objectContaining({ 111 | name: 'Alie', 112 | age: 18 113 | })); 114 | 115 | }); 116 | 117 | }); 118 | 119 | describe('addIndex', () => { 120 | beforeEach(async () => { 121 | await personCollection.removeIndex('testIndex'); 122 | }); 123 | 124 | it('can create named indexes', async () => { 125 | const f = await personCollection.addIndex(['name'], 'testIndex'); 126 | expect(personCollection._indexes[3]).toBeTruthy(); 127 | expect(personCollection._indexes[3]?.name).toBe('testIndex'); 128 | expect(personCollection._indexes[3]?.fields?.includes('name')).toBe(true); 129 | }); 130 | 131 | it('can create compound indexes with multiple fields', async () => { 132 | await personCollection.addIndex(['name', 'otherInfo'], 'testIndex'); 133 | expect(personCollection._indexes[3]).toBeTruthy(); 134 | expect(personCollection._indexes[3]?.name).toBe('testIndex'); 135 | expect(personCollection._indexes[3]?.fields?.includes('name')).toBe(true); 136 | expect(personCollection._indexes[3]?.fields?.includes('otherInfo')).toBe(true); 137 | }); 138 | 139 | it('adds $collectionType field to indexes', async () => { 140 | // Adding the $collectionType to every index of this collection for fast collection-level querying 141 | await personCollection.addIndex(['name'], 'testIndex'); 142 | expect(personCollection._indexes[3]).toBeTruthy(); 143 | expect(personCollection._indexes[3]?.fields?.length).toBe(2); 144 | expect(personCollection._indexes[3]?.fields?.includes('name')).toBe(true); 145 | expect(personCollection._indexes[3]?.fields?.includes('$collectionType')).toBe(true); 146 | }); 147 | }); 148 | 149 | describe('bulkUpsert', () => { 150 | it('creates documents in array', async () => { 151 | 152 | const bulkPersons = await personCollection.bulkUpsert([ 153 | { 154 | name: 'tifa', 155 | age: 25 156 | }, 157 | { 158 | name: 'cloud', 159 | age: 28 160 | }, 161 | { 162 | name: 'sephiroth', 163 | age: 999 164 | }, 165 | ]); 166 | 167 | expect(bulkPersons).toHaveLength(3); 168 | expect(bulkPersons[0].id).toBeTruthy(); 169 | expect(bulkPersons[1].id).toBeTruthy(); 170 | expect(bulkPersons[2].id).toBeTruthy(); 171 | }); 172 | it('updates documents in an array', async () => { 173 | 174 | const p = makePerson(); 175 | const person = await personCollection.upsert(p); 176 | expect(person._id).toBeTruthy(); 177 | 178 | person.age = 57; 179 | const bulkPersons = await personCollection.bulkUpsert([ 180 | { 181 | name: 'tifa', 182 | age: 25 183 | }, 184 | { 185 | name: 'cloud', 186 | age: 28 187 | }, 188 | { 189 | name: 'sephiroth', 190 | age: 999 191 | }, 192 | person 193 | ]); 194 | 195 | expect(bulkPersons).toHaveLength(4); 196 | expect(bulkPersons[0].id).toBeTruthy(); 197 | expect(bulkPersons[1].id).toBeTruthy(); 198 | expect(bulkPersons[2].id).toBeTruthy(); 199 | expect(bulkPersons[3].id).toBeTruthy(); 200 | expect(bulkPersons[3].id).toBe(person._id); 201 | }); 202 | 203 | it('calls onChangeUpserted for each item', async () => { 204 | 205 | const spy = jest.spyOn(personCollection, 'onChangeUpserted'); 206 | const bulkPersons = await personCollection.bulkUpsert([ 207 | { 208 | name: 'tifa1', 209 | age: 11 210 | }, 211 | { 212 | name: 'cloud1', 213 | age: 22 214 | }, 215 | { 216 | name: 'sephiroth1', 217 | age: 33 218 | }, 219 | ]); 220 | 221 | await waitFor() 222 | expect(spy).toHaveBeenCalledWith(expect.objectContaining({ 223 | name: 'tifa1', 224 | age: 11 225 | })); 226 | expect(spy).toHaveBeenCalledWith(expect.objectContaining({ 227 | name: 'cloud1', 228 | age: 22 229 | })); 230 | expect(spy).toHaveBeenCalledWith(expect.objectContaining({ 231 | name: 'sephiroth1', 232 | age: 33 233 | })); 234 | 235 | }); 236 | }); 237 | 238 | describe('with data', () => { 239 | 240 | let bulkPersons; 241 | let bulkAccounts; 242 | 243 | beforeEach(async () => { 244 | 245 | bulkPersons = await personCollection.bulkUpsert([ 246 | { 247 | name: 'tifa', 248 | age: 25 249 | }, 250 | { 251 | name: 'cloud', 252 | age: 28 253 | }, 254 | { 255 | name: 'Kingsley', 256 | age: 28 257 | }, 258 | { 259 | name: 'sephiroth', 260 | age: 999 261 | } 262 | ]); 263 | expect(bulkPersons).toHaveLength(4); 264 | 265 | bulkAccounts = await accountCollection.bulkUpsert([ 266 | new Account({ 267 | name: 'Darmok', 268 | age: 202 269 | }), 270 | new Account({ 271 | name: 'Jalad', 272 | age: 102 273 | }), 274 | new Account({ 275 | name: 'Tanagra', 276 | age: 102 277 | }) 278 | ]); 279 | expect(bulkAccounts).toHaveLength(3); 280 | 281 | }); 282 | afterEach(async () => PouchORM.clearDatabase(dbName)); 283 | 284 | describe('finding with', () => { 285 | describe('find', () => { 286 | it('gets all interface-based items matching non-indexed fields', async () => { 287 | 288 | const guys = await personCollection.find({name: 'Kingsley'}); 289 | expect(guys).toHaveLength(1); 290 | }); 291 | it('gets all interface-based items matching indexed fields', async () => { 292 | 293 | const guys = await personCollection.find({age: 28}); 294 | expect(guys).toHaveLength(2); 295 | }); 296 | it('gets all class-based items matching indexed fields', async () => { 297 | 298 | const accounts = await accountCollection.find({age: 102}); 299 | expect(accounts).toHaveLength(2); 300 | }); 301 | }); 302 | 303 | describe('findById', () => { 304 | it('gets interface-based item by id', async () => { 305 | 306 | const tifa = await personCollection.findById(bulkPersons[0].id); 307 | expect(tifa).toBeTruthy(); 308 | expect(tifa._id).toBe(bulkPersons[0].id); 309 | expect(tifa.name).toBe('tifa'); 310 | }); 311 | 312 | it('gets class-based item by id', async () => { 313 | 314 | const jalad = await accountCollection.findById(bulkAccounts[1].id); 315 | expect(jalad).toBeTruthy(); 316 | expect(jalad._id).toBe(bulkAccounts[1].id); 317 | expect(jalad.name).toBe('Jalad'); 318 | }); 319 | 320 | it('returns null if item does not exist', async () => { 321 | 322 | const nada = await personCollection.findById('zilch'); 323 | expect(nada).toBeFalsy(); 324 | }); 325 | }); 326 | }); 327 | describe('removeById', () => { 328 | 329 | it('removes by id', async () => { 330 | 331 | const p1 = await personCollection.find({}); 332 | expect(p1).toHaveLength(4); 333 | 334 | const cloud = await personCollection.findById(p1[1]._id); 335 | expect(cloud).toBeTruthy(); 336 | 337 | await personCollection.removeById(cloud._id); 338 | 339 | const p2 = await personCollection.find({}); 340 | expect(p2).toHaveLength(3); 341 | 342 | const cloud2 = await personCollection.findById(p1[1]._id); 343 | expect(cloud2).toBeFalsy(); 344 | 345 | }); 346 | 347 | // it('calls onChangeDeleted', async () => { 348 | // jest.resetAllMocks() 349 | // jest.clearAllMocks() 350 | // 351 | // const p1 = await personCollection.find({}); 352 | // 353 | // const cloud = await personCollection.findById(p1[1]._id); 354 | // expect(cloud).toBeTruthy(); 355 | // 356 | // const spy = jest.spyOn(personCollection, 'onChangeDeleted'); 357 | // await personCollection.removeById(cloud._id); 358 | // 359 | // await waitFor(1000) 360 | // expect(spy).toHaveBeenCalledTimes(1) 361 | // expect(spy).toHaveBeenCalledWith(expect.objectContaining(p1[1])) 362 | // }) 363 | }); 364 | describe('bulkRemove', () => { 365 | 366 | it('removes all documents in array from database', async () => { 367 | const guys = await personCollection.find({age: 28}); 368 | await personCollection.bulkRemove(guys); 369 | 370 | const newguys = await personCollection.find({age: 28}); 371 | expect(newguys).toHaveLength(0); 372 | }); 373 | 374 | }); 375 | describe('upsert with', () => { 376 | 377 | it('new instance of class Model', async () => { 378 | const a = new Account({ 379 | name: 'Spyder', 380 | age: 32 381 | }); 382 | const account = await accountCollection.upsert(a); 383 | expect(account.age).toBe(a.age); 384 | }); 385 | 386 | it('validation of class Model properties', async () => { 387 | const a = new Account({ 388 | name: 'Spyder', 389 | age: '32' as unknown as number 390 | }); 391 | let error: ValidationError[]; 392 | 393 | PouchORM.VALIDATE = ClassValidate.ON_AND_REJECT; 394 | 395 | try { 396 | await accountCollection.upsert(a); 397 | } catch (err) { 398 | error = err; 399 | } 400 | 401 | expect(error[0]).toBeInstanceOf(ValidationError); 402 | }); 403 | 404 | 405 | }); 406 | }); 407 | 408 | }); --------------------------------------------------------------------------------