├── .editorconfig ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .opensource └── project.json ├── .prettierrc ├── LICENSE ├── README.md ├── lerna.json ├── package.json ├── packages ├── core │ ├── .mocharc.yaml │ ├── README.md │ ├── package.json │ ├── src │ │ ├── index.ts │ │ └── storage │ │ │ ├── collections.test.ts │ │ │ ├── collections.ts │ │ │ ├── error.ts │ │ │ ├── model.ts │ │ │ ├── query.ts │ │ │ ├── repository.ts │ │ │ ├── transformer.ts │ │ │ └── types.ts │ ├── tsconfig.esm2015.json │ ├── tsconfig.json │ └── tsconfig.types.json ├── firestore │ ├── .env.sample │ ├── .env.test │ ├── .mocharc.yaml │ ├── README.md │ ├── package.json │ ├── scripts │ │ └── prepare │ ├── src │ │ └── lib │ │ │ ├── index.ts │ │ │ └── storage │ │ │ ├── definitions.test.ts │ │ │ ├── export.ts │ │ │ ├── migrations.test.ts │ │ │ ├── migrations.ts │ │ │ ├── query.ts │ │ │ ├── repository.test.ts │ │ │ ├── repository.ts │ │ │ ├── test-utils.ts │ │ │ ├── transaction.test.ts │ │ │ ├── transaction.ts │ │ │ └── utils.ts │ └── tsconfig.json ├── function-utils │ ├── README.md │ ├── package.json │ ├── src │ │ └── lib │ │ │ ├── index.ts │ │ │ ├── integration.test.ts │ │ │ └── utils.ts │ └── tsconfig.json ├── indexes │ ├── .mocharc.yaml │ ├── README.md │ ├── bin │ │ └── firestore-indexes.js │ ├── package.json │ ├── src │ │ ├── lib │ │ │ ├── index.ts │ │ │ ├── index_manger.ts │ │ │ └── path.ts │ │ └── test │ │ │ ├── index_manager_example.ts │ │ │ └── index_manager_test.ts │ └── tsconfig.json └── tsconfig-base.json ├── pnpm-lock.yaml └── pnpm-workspace.yaml /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = tab 6 | indent_size = 4 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [{*.json,*.yml}] 13 | indent_style = space 14 | indent_size = 2 15 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Firestore Storage CI 2 | 3 | on: [push] 4 | jobs: 5 | build-and-test: 6 | runs-on: ubuntu-latest 7 | env: 8 | GOOGLE_APPLICATION_CREDENTIALS: '/tmp/google-credentials-sa-node-${{ matrix.node }}-${{ matrix.package }}.json' 9 | strategy: 10 | matrix: 11 | package: [ core, firestore, indexes, function-utils ] 12 | node: [ 16 ] 13 | fail-fast: false 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Install pnpm 17 | uses: pnpm/action-setup@v2 18 | with: 19 | version: ^8.6.5 20 | - uses: actions/setup-node@v3 21 | with: 22 | node-version: ${{ matrix.node }} 23 | cache: 'pnpm' 24 | - name: Install dependencies 25 | run: pnpm install --frozen-lockfile 26 | - name: Build package 27 | run: | 28 | cd packages/${{ matrix.package }} 29 | pnpm build 30 | - name: Test package 31 | env: 32 | FIRESTORE_EMULATOR_HOST: 'localhost:8080' 33 | run: | 34 | cd packages/${{ matrix.package }} 35 | pnpm test 36 | - name: Publish Test Results 37 | uses: EnricoMi/publish-unit-test-result-action@v2 38 | if: always() 39 | with: 40 | github_token: ${{ secrets.GITHUB_TOKEN }} 41 | files: "packages/${{ matrix.package }}/test-results.xml" 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Node ### 2 | # Logs 3 | logs 4 | *.log 5 | npm-debug.log* 6 | 7 | # Dependency directories 8 | node_modules/ 9 | jspm_packages/ 10 | 11 | # dotenv environment variables file 12 | .env 13 | 14 | # Typescript build 15 | dist/ 16 | tsconfig.tsbuildinfo 17 | 18 | ### IDEs ### 19 | .idea/ 20 | -------------------------------------------------------------------------------- /.opensource/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Firestore Storage", 3 | "platforms": [ 4 | "Node", 5 | "Admin" 6 | ], 7 | "content": "README.md", 8 | "related": [ 9 | "firebase/firebase-admin-node" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "printWidth": 120, 4 | "singleAttributePerLine": false, 5 | "bracketSameLine": true, 6 | "useTabs": true, 7 | "tabWidth": 4 8 | } 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Freshfox OG 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Firestore Storage 2 | [![Build Status](https://github.com/freshfox/firestore-storage/actions/workflows/main.yml/badge.svg)](https://github.com/freshfox/firestore-storage/actions) 3 | 4 | Typesafe repositories around Firestore providing a straightforward API to read and write documents. 5 | 6 | ## Usage 7 | ```bash 8 | npm i firestore-storage-core firestore-storage 9 | ``` 10 | 11 | ```typescript 12 | import { BaseModel } from 'firebase-storage-core'; 13 | import { initializeApp } from 'firebase-admin/app'; 14 | import { getFirestore } from 'firebase-admin/firestore'; 15 | initializeApp(); 16 | 17 | // restaurants/{restaurantId} 18 | const restaurantRepo = new RestaurantRepository(getFirestore()); 19 | 20 | const restaurant = await restaurantRepo.save({ 21 | name: 'FreshFoods', 22 | type: 'vegan' 23 | }); 24 | console.log(restaurant); 25 | /* 26 | { 27 | id: '0vdxYqEisf5vwJLhyLjA', 28 | name: 'FreshFoods', 29 | _rawPath: 'restaurants/0vdxYqEisf5vwJLhyLjA' 30 | }*/ 31 | 32 | // Query restaurants based on properties 33 | await restaurantRepo.list({ 34 | type: 'vegan' 35 | }); 36 | 37 | // More complex queries 38 | await restaurantRepo.query((qb) => { 39 | return qb 40 | .where((r) => r.type, '==', 'steak') 41 | .where((r) => r.address.city, '==', 'NY') 42 | }); 43 | ``` 44 | The properties `id` and `_rawPath` from `BaseModel` are dynamically added during reads 45 | and removed before writes. 46 | 47 | ### Nested collections 48 | 49 | When working with nested collections, read and write methods require a parameter 50 | to supply a map of all parent document ids 51 | ```typescript 52 | // restaurants/{restaurantId}/reviews/{reviewId} 53 | const reviewRepo = new ReviewRepository(getFirestore()); 54 | 55 | const review = await reviewRepo.save({ 56 | userId: 'my-user-uid-123', 57 | stars: 5 58 | }, { 59 | restaurantId: '0vdxYqEisf5vwJLhyLjA' 60 | }); 61 | console.log(review); 62 | /* 63 | { 64 | id: 'a393f73b884c4a0981c0', 65 | userId: 'my-user-uid-123', 66 | stars: 5 67 | _rawPath: 'restaurants/0vdxYqEisf5vwJLhyLjA/reviews/a393f73b884c4a0981c0' 68 | }*/ 69 | ``` 70 | 71 | ## Defining collections and repositories 72 | 73 | Create repository classes for each collection you want to query documents from. For example, 74 | if you want to query documents to query from the `users` collection you create a class `UserRepository` extending `BaseRepository`. 75 | Each repository provides a list of functions for saving, querying and deleting documents, 76 | and you can extend each repository based on your needs. 77 | 78 | ```typescript 79 | export namespace Collections { 80 | // To define restaurants/{restaurantId}. 81 | export const Restaurants = new CollectionPath( 82 | // Name of the collection 83 | 'restaurants', 84 | // Template variable name and property name on the id map 85 | 'restaurantId'); 86 | 87 | // When defining nested collections a few generics are required 88 | // restaurants/{restaurantId}/reviews/{reviewId} 89 | export const Restaurants_Reviews = new CollectionPath< 90 | // Template variable 91 | 'reviewId', 92 | // Type of the id on the model 93 | string, 94 | // Type of the id map from the parent collection 95 | DocumentIds 96 | >( 97 | // Name of the collection 98 | 'reviews', 99 | // Template variable name and property name on the id map 100 | 'reviewId', 101 | // Path of the parent collection 102 | Restaurants 103 | ); 104 | } 105 | ``` 106 | ```typescript 107 | // Path to document: restaurants/0vdxYqEisf5vwJLhyLjA/reviews/a393f73b884c4a0981c0 108 | Collections.Restaurants_Reviews.doc({ 109 | restaurantId: '0vdxYqEisf5vwJLhyLjA', 110 | reviewId: 'a393f73b884c4a0981c0' 111 | }) 112 | 113 | // Path to collection: restaurants/0vdxYqEisf5vwJLhyLjA/reviews 114 | Collections.Restaurants_Reviews.collection({ 115 | restaurantId: '0vdxYqEisf5vwJLhyLjA' 116 | }) 117 | 118 | // Path template: restaurants/{restaurantId}/reviews/{reviewId} 119 | Collections.Restaurants_Reviews.path(); 120 | 121 | // Parse ids from path 122 | Collections.Restaurants_Reviews.parse( 123 | 'restaurants/0vdxYqEisf5vwJLhyLjA/reviews/a393f73b884c4a0981c0' 124 | ); 125 | /** 126 | * { 127 | * restaurantId: '0vdxYqEisf5vwJLhyLjA', 128 | * reviewId: 'a393f73b884c4a0981c0' 129 | * } 130 | */ 131 | ``` 132 | 133 | ### Creating repositories 134 | 135 | ```typescript 136 | import { BaseRepository } from 'firestore-storage'; 137 | import { Repository } from 'firestore-storage-core'; 138 | 139 | interface Review { 140 | userId: string; 141 | stars: number; 142 | } 143 | 144 | @Repository({ 145 | path: Collections.Restaurants_Reviews 146 | }) 147 | export class ReviewRepository extends BaseRepository { 148 | 149 | constructor() { 150 | super(getFirestore()); 151 | } 152 | } 153 | ``` 154 | 155 | ### Return value conventions for methods 156 | 157 | - `find*()` methods return the document or null when no result was found 158 | - `get*()` methods always return the document and will throw an error when no result was found 159 | - `list*()` methods always return an array and never null. When no result is found, the array is empty 160 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "npmClient": "pnpm", 3 | "packages": [ 4 | "packages/core", 5 | "packages/firestore", 6 | "packages/indexes" 7 | ], 8 | "useWorkspaces": true, 9 | "version": "independent" 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "firestore-storage", 3 | "version": "4.0.0", 4 | "description": "A typed wrapper around Firestore including a query-builder and an in-memory implementation for testing", 5 | "engines": { 6 | "node": ">=14" 7 | }, 8 | "private": true, 9 | "scripts": { 10 | "test": "lerna run --stream test", 11 | "build": "lerna run --stream build", 12 | "preversion": "git pull && pnpm build", 13 | "release": "pnpm build && lerna publish", 14 | "lerna": "lerna", 15 | "emulators": "firebase emulators:start --only firestore --project firestore-storage-local", 16 | "prettier": "prettier" 17 | }, 18 | "workspaces": { 19 | "packages": [ 20 | "packages/*" 21 | ] 22 | }, 23 | "repository": { 24 | "type": "git", 25 | "url": "git@github.com:freshfox/firestore-storage.git" 26 | }, 27 | "author": "Dominic Bartl", 28 | "license": "MIT", 29 | "homepage": "https://github.com/freshfox/firestore-storage", 30 | "devDependencies": { 31 | "esbuild": "^0.19.11", 32 | "firebase-tools": "^12.8.0", 33 | "lerna": "^4.0.0", 34 | "mocha-junit-reporter": "^2.2.1", 35 | "prettier": "^2.8.6" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /packages/core/.mocharc.yaml: -------------------------------------------------------------------------------- 1 | timeout: 20000 2 | exit: true 3 | require: 4 | - ts-node/register 5 | - tsconfig-paths/register 6 | -------------------------------------------------------------------------------- /packages/core/README.md: -------------------------------------------------------------------------------- 1 | # Firestore Storage 2 | [![Build Status](https://github.com/freshfox/firestore-storage/actions/workflows/main.yml/badge.svg)](https://github.com/freshfox/firestore-storage/actions) 3 | 4 | Typesafe repositories around Firestore providing a straightforward API to read and write documents. 5 | 6 | ## Usage 7 | ```bash 8 | npm i firestore-storage-core firestore-storage 9 | ``` 10 | 11 | ```typescript 12 | import { BaseModel } from 'firebase-storage-core'; 13 | import { initializeApp } from 'firebase-admin/app'; 14 | import { getFirestore } from 'firebase-admin/firestore'; 15 | initializeApp(); 16 | 17 | // restaurants/{restaurantId} 18 | const restaurantRepo = new RestaurantRepository(getFirestore()); 19 | 20 | const restaurant = await restaurantRepo.save({ 21 | name: 'FreshFoods', 22 | type: 'vegan' 23 | }); 24 | console.log(restaurant); 25 | /* 26 | { 27 | id: '0vdxYqEisf5vwJLhyLjA', 28 | name: 'FreshFoods', 29 | _rawPath: 'restaurants/0vdxYqEisf5vwJLhyLjA' 30 | }*/ 31 | 32 | // Query restaurants based on properties 33 | await restaurantRepo.list({ 34 | type: 'vegan' 35 | }); 36 | 37 | // More complex queries 38 | await restaurantRepo.query((qb) => { 39 | return qb 40 | .where((r) => r.type, '==', 'steak') 41 | .where((r) => r.address.city, '==', 'NY') 42 | }); 43 | ``` 44 | The properties `id` and `_rawPath` from `BaseModel` are dynamically added during reads 45 | and removed before writes. 46 | 47 | ### Nested collections 48 | 49 | When working with nested collections, read and write methods require a parameter 50 | to supply a map of all parent document ids 51 | ```typescript 52 | // restaurants/{restaurantId}/reviews/{reviewId} 53 | const reviewRepo = new ReviewRepository(getFirestore()); 54 | 55 | const review = await reviewRepo.save({ 56 | userId: 'my-user-uid-123', 57 | stars: 5 58 | }, { 59 | restaurantId: '0vdxYqEisf5vwJLhyLjA' 60 | }); 61 | console.log(review); 62 | /* 63 | { 64 | id: 'a393f73b884c4a0981c0', 65 | userId: 'my-user-uid-123', 66 | stars: 5 67 | _rawPath: 'restaurants/0vdxYqEisf5vwJLhyLjA/reviews/a393f73b884c4a0981c0' 68 | }*/ 69 | ``` 70 | 71 | ## Defining collections and repositories 72 | 73 | Create repository classes for each collection you want to query documents from. For example, 74 | if you want to query documents to query from the `users` collection you create a class `UserRepository` extending `BaseRepository`. 75 | Each repository provides a list of functions for saving, querying and deleting documents, 76 | and you can extend each repository based on your needs. 77 | 78 | ```typescript 79 | export namespace Collections { 80 | // To define restaurants/{restaurantId}. 81 | export const Restaurants = new CollectionPath( 82 | // Name of the collection 83 | 'restaurants', 84 | // Template variable name and property name on the id map 85 | 'restaurantId'); 86 | 87 | // When defining nested collections a few generics are required 88 | // restaurants/{restaurantId}/reviews/{reviewId} 89 | export const Restaurants_Reviews = new CollectionPath< 90 | // Template variable 91 | 'reviewId', 92 | // Type of the id on the model 93 | string, 94 | // Type of the id map from the parent collection 95 | DocumentIds 96 | >( 97 | // Name of the collection 98 | 'reviews', 99 | // Template variable name and property name on the id map 100 | 'reviewId', 101 | // Path of the parent collection 102 | Restaurants 103 | ); 104 | } 105 | ``` 106 | ```typescript 107 | // Path to document: restaurants/0vdxYqEisf5vwJLhyLjA/reviews/a393f73b884c4a0981c0 108 | Collections.Restaurants_Reviews.doc({ 109 | restaurantId: '0vdxYqEisf5vwJLhyLjA', 110 | reviewId: 'a393f73b884c4a0981c0' 111 | }) 112 | 113 | // Path to collection: restaurants/0vdxYqEisf5vwJLhyLjA/reviews 114 | Collections.Restaurants_Reviews.collection({ 115 | restaurantId: '0vdxYqEisf5vwJLhyLjA' 116 | }) 117 | 118 | // Path template: restaurants/{restaurantId}/reviews/{reviewId} 119 | Collections.Restaurants_Reviews.path(); 120 | 121 | // Parse ids from path 122 | Collections.Restaurants_Reviews.parse( 123 | 'restaurants/0vdxYqEisf5vwJLhyLjA/reviews/a393f73b884c4a0981c0' 124 | ); 125 | /** 126 | * { 127 | * restaurantId: '0vdxYqEisf5vwJLhyLjA', 128 | * reviewId: 'a393f73b884c4a0981c0' 129 | * } 130 | */ 131 | ``` 132 | 133 | ### Creating repositories 134 | 135 | ```typescript 136 | import { BaseRepository } from 'firestore-storage'; 137 | import { Repository } from 'firestore-storage-core'; 138 | 139 | interface Review { 140 | userId: string; 141 | stars: number; 142 | } 143 | 144 | @Repository({ 145 | path: Collections.Restaurants_Reviews 146 | }) 147 | export class ReviewRepository extends BaseRepository { 148 | 149 | constructor() { 150 | super(getFirestore()); 151 | } 152 | } 153 | ``` 154 | 155 | ### Return value conventions for methods 156 | 157 | - `find*()` methods return the document or null when no result was found 158 | - `get*()` methods always return the document and will throw an error when no result was found 159 | - `list*()` methods always return an array and never null. When no result is found, the array is empty 160 | -------------------------------------------------------------------------------- /packages/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "firestore-storage-core", 3 | "version": "7.0.3", 4 | "description": "Core classes, types and utilities for firestore-storage", 5 | "license": "MIT", 6 | "main": "dist/cjs/index.js", 7 | "module": "dist/esm2015/index.js", 8 | "types": "dist/types/index.d.ts", 9 | "engines": { 10 | "node": ">=10.10.0" 11 | }, 12 | "files": [ 13 | "dist", 14 | "README.md", 15 | "package.json" 16 | ], 17 | "scripts": { 18 | "test": "NODE_ENV=test mocha './src/**/*.test.ts' --reporter mocha-junit-reporter", 19 | "build": "pnpm build:cjs && pnpm build:esm && pnpm build:types", 20 | "build:cjs": "tsc --project tsconfig.json", 21 | "build:esm": "tsc --project tsconfig.esm2015.json", 22 | "build:types": "tsc --project tsconfig.types.json", 23 | "clean": "rm -rf dist tsconfig.tsbuildinfo", 24 | "fss": "fss" 25 | }, 26 | "author": "Dominic Bartl", 27 | "repository": { 28 | "type": "git", 29 | "url": "git@github.com:freshfox/firestore-storage.git" 30 | }, 31 | "homepage": "https://github.com/freshfox/firestore-storage", 32 | "devDependencies": { 33 | "@types/lodash": "^4.14.191", 34 | "@types/mocha": "^9.0.0", 35 | "@types/node": "10", 36 | "mocha": "^9.1.3", 37 | "should": "^13.2.3", 38 | "ts-node": "^10.7.0", 39 | "typescript": "^4.5.2" 40 | }, 41 | "dependencies": { 42 | "lodash": "^4.17.21", 43 | "reflect-metadata": "^0.1.13", 44 | "ts-object-path": "^0.1.2" 45 | }, 46 | "gitHead": "0b06debcfa978dcfd12f74599cded3802179e34b" 47 | } 48 | -------------------------------------------------------------------------------- /packages/core/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './storage/collections'; 2 | export * from './storage/error'; 3 | export * from './storage/model'; 4 | export * from './storage/query'; 5 | export * from './storage/repository'; 6 | export * from './storage/transformer'; 7 | export * from './storage/types'; 8 | -------------------------------------------------------------------------------- /packages/core/src/storage/collections.test.ts: -------------------------------------------------------------------------------- 1 | import should from 'should'; 2 | import { CollectionPath, DocumentIds } from './collections'; 3 | 4 | describe('CollectionPath', function () { 5 | const Accounts = new CollectionPath('accounts', 'accountId'); 6 | const Users = new CollectionPath<'userId', string, DocumentIds>('users', 'userId', Accounts); 7 | 8 | it('should generate a path template', async () => { 9 | Users.path().should.eql('/accounts/{accountId}/users/{userId}'); 10 | }); 11 | 12 | it('should generate a collection path', async () => { 13 | Users.collection({ accountId: '1' }).should.eql('/accounts/1/users'); 14 | }); 15 | 16 | it('should generate a document path', async () => { 17 | Users.doc({ accountId: '1', userId: '2' }).should.eql('/accounts/1/users/2'); 18 | }); 19 | 20 | it('should generate a map of document ids', async () => { 21 | Users.toDocIds({ accountId: '1' }, '2').should.eql({ 22 | accountId: '1', 23 | userId: '2', 24 | }); 25 | }); 26 | 27 | it('should get collection name', async () => { 28 | Users.collectionName.should.eql('users'); 29 | }); 30 | 31 | it('should parse a path and extract ids', async () => { 32 | Users.parse('/accounts/1/users/2').should.eql({ 33 | accountId: '1', 34 | userId: '2', 35 | }); 36 | }); 37 | 38 | it('should fail to parse wrong paths', async () => { 39 | should(() => Users.parse('/accounts/1/users/')).throw(); 40 | should(() => Users.parse('/accounts/1/')).throw(); 41 | should(() => Users.parse('/users/1')).throw(); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /packages/core/src/storage/collections.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A generic type used to generate a new type from a given collection path. The generated type is 3 | * a map with keys for each id of this path leading up to this collection. 4 | */ 5 | export type CollectionIds, D = void> = C extends CollectionPath< 6 | infer IdKey, 7 | infer IdType, 8 | infer P 9 | > 10 | ? P 11 | : D; 12 | 13 | /** 14 | * A generic type used to generate a new type from a given collection path. The generated type is 15 | * a map with keys for each id of this path leading up to a document in this collection. 16 | */ 17 | export type DocumentIds> = C extends CollectionPath< 18 | infer IdKey, 19 | infer IdType, 20 | infer P 21 | > 22 | ? P extends void 23 | ? { [k in IdKey]: IdType } 24 | : P & { 25 | [k in IdKey]: IdType; 26 | } 27 | : never; 28 | 29 | export class CollectionPath { 30 | constructor( 31 | public readonly collectionName: string, 32 | public readonly idKey: IdKey, 33 | private parent?: CollectionPath 34 | ) {} 35 | 36 | /** 37 | * @returns The path as a template. e.g. /accounts/{accountId}/users/{userId} 38 | */ 39 | path(): string { 40 | return `${this.parent ? this.parent.path() : ''}/${this.collectionName}/{${this.idKey}}`; 41 | } 42 | 43 | /** 44 | * Generates the path to this collection 45 | * @param ids Ids of all parent documents 46 | */ 47 | collection(ids: P): string { 48 | return `${this.parent ? this.parent.doc(ids) : ''}/${this.collectionName}`; 49 | } 50 | 51 | /** 52 | * Generates the path to a single document in this collection 53 | * @param ids Ids of this document and all parent documents 54 | */ 55 | doc(ids: DocumentIds): string { 56 | const id = ids[this.idKey]; 57 | if (!id) { 58 | console.error(ids); 59 | throw new Error(`Missing ${this.idKey} in ids`); 60 | } 61 | return `${this.collection(ids as CollectionIds)}/${id}`; 62 | } 63 | 64 | /** 65 | * Generates an id map for a document in this collection including all parent document ids 66 | * @param ids 67 | * @param docId 68 | */ 69 | toDocIds(ids: CollectionIds, docId: IdType): DocumentIds { 70 | return { 71 | ...ids, 72 | [this.idKey]: docId, 73 | } as DocumentIds; 74 | } 75 | 76 | parse(path: string): DocumentIds { 77 | const parts = path.split('/'); 78 | // Remove optional first / 79 | if (parts[0] === '') { 80 | parts.splice(0, 1); 81 | } 82 | return this.extractId({}, parts) as DocumentIds; 83 | } 84 | 85 | protected extractId>(map: Partial, path: string[]): Partial { 86 | if (this.parent) { 87 | this.parent.extractId(map, path); 88 | } 89 | if (path[0] === this.collectionName && path[1] && typeof path[1] === 'string') { 90 | map[this.idKey] = path[1] as any; 91 | path.splice(0, 2); 92 | return map; 93 | } 94 | throw new Error(`Unable to get id for ${this.collectionName} from ${path.join('/')}`); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /packages/core/src/storage/error.ts: -------------------------------------------------------------------------------- 1 | import { BaseModel } from './types'; 2 | 3 | export class FirestoreStorageError extends Error { 4 | constructor(public rawPath: string, public readonly ids: object, msg?: string) { 5 | super(`Unable to get document from ${rawPath}. ${JSON.stringify(ids)} (${msg || ''})`); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/core/src/storage/model.ts: -------------------------------------------------------------------------------- 1 | export interface ModelMetaInternal { 2 | id?: string; 3 | rawPath?: string; 4 | } 5 | 6 | export type ModelMeta = R extends true ? Required : ModelMetaInternal; 7 | -------------------------------------------------------------------------------- /packages/core/src/storage/query.ts: -------------------------------------------------------------------------------- 1 | import { BaseModel, ModelQuery } from './types'; 2 | import { getPath } from 'ts-object-path'; 3 | 4 | export type WhereProp = string | ((t: T) => unknown); 5 | 6 | export abstract class BaseQuery { 7 | protected abstract applyWhere(key: string, operator: Op, value: any): this; 8 | abstract execute(): R; 9 | 10 | where(prop: WhereProp, op: Op, value: any) { 11 | return this.applyWhere(this.getWhereProp(prop), op, value); 12 | } 13 | 14 | whereAll>(attributes: K | null) { 15 | if (attributes) { 16 | const keys = Object.keys(attributes) as (keyof K)[]; 17 | return keys.reduce((query, key) => { 18 | return query.applyWhere(String(key), '==' as any, attributes[key]); 19 | }, this); 20 | } 21 | return this; 22 | } 23 | 24 | protected getWhereProp(prop: WhereProp): string { 25 | if (typeof prop === 'string') return prop; 26 | return getPath unknown>(prop).join('.'); 27 | } 28 | 29 | equals(prop: WhereProp, value: any) { 30 | return this.where(prop, '==' as any, value); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /packages/core/src/storage/repository.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | import { DEFAULT_DOCUMENT_TRANSFORMER, IDocumentTransformer } from './transformer'; 3 | import { CollectionIds, CollectionPath, DocumentIds } from './collections'; 4 | import { BaseModel, ModelDataOnly, ModelDataWithId, PatchUpdate } from './types'; 5 | 6 | const transformerMetaKey = 'firestore:transformer'; 7 | const pathMetaKey = 'firestore:path'; 8 | 9 | /** 10 | * Base class for platform independent repositories. Contains methods accessing its metadata supplied 11 | * via the Typescript @Repository decorator 12 | */ 13 | export abstract class BaseRepository, DocSnap> { 14 | private readonly collectionPath: Path; 15 | private readonly transformer: IDocumentTransformer; 16 | 17 | protected constructor() { 18 | this.collectionPath = Reflect.getMetadata(pathMetaKey, this.constructor); 19 | if (!this.collectionPath) { 20 | throw new Error(`Unable to get path for ${this.constructor.name}. Did you add the @Repository decorator`); 21 | } 22 | this.transformer = Reflect.getMetadata(transformerMetaKey, this.constructor); 23 | } 24 | 25 | protected abstract fromFirestoreToObject(snap: DocSnap): T | null; 26 | 27 | toFirestoreDocument(doc: T): { id: string; data: ModelDataOnly }; 28 | toFirestoreDocument(doc: ModelDataOnly | PatchUpdate>): { 29 | id: undefined; 30 | data: ModelDataOnly; 31 | }; 32 | toFirestoreDocument(data: T | ModelDataOnly | PatchUpdate>): { 33 | id: string | undefined; 34 | data: ModelDataOnly; 35 | } { 36 | return this.getTransformer().toFirestoreDocument(data); 37 | } 38 | 39 | /** 40 | * Returns the path to the document as a string 41 | * @param ids - A map containing all ids up to the document itself 42 | */ 43 | getDocumentPath(ids: DocumentIds): string { 44 | return this.getPath().doc(ids); 45 | } 46 | 47 | /** 48 | * Returns the path to the collection as a string 49 | * @param ids - A map containing all ids excluding the last documents id 50 | */ 51 | getCollectionPath(ids: CollectionIds) { 52 | return this.getPath().collection(ids); 53 | } 54 | 55 | /** 56 | * Returns the standalone collection name 57 | */ 58 | getCollectionName() { 59 | return this.getPath().collectionName; 60 | } 61 | 62 | getPath(): Path { 63 | return this.collectionPath; 64 | } 65 | 66 | protected getTransformer(): IDocumentTransformer { 67 | return this.transformer; 68 | } 69 | } 70 | 71 | export function Repository(args: { 72 | path: CollectionPath; 73 | transformer?: IDocumentTransformer; 74 | }): ClassDecorator { 75 | return (target) => { 76 | Reflect.defineMetadata(pathMetaKey, args.path, target); 77 | Reflect.defineMetadata(transformerMetaKey, args.transformer || DEFAULT_DOCUMENT_TRANSFORMER, target); 78 | }; 79 | } 80 | -------------------------------------------------------------------------------- /packages/core/src/storage/transformer.ts: -------------------------------------------------------------------------------- 1 | import { ModelMeta } from './model'; 2 | import { cloneDeep } from 'lodash'; 3 | import { BaseModel, ModelDataOnly, ModelDataWithId, PatchUpdate } from './types'; 4 | 5 | export interface IDocumentTransformer { 6 | fromFirestoreToObject(data: ModelDataOnly, meta: ModelMeta): T; 7 | 8 | toFirestoreDocument(doc: T): { id: string; data: ModelDataOnly }; 9 | toFirestoreDocument(doc: ModelDataOnly | PatchUpdate>): { 10 | id: undefined; 11 | data: ModelDataOnly; 12 | }; 13 | toFirestoreDocument(doc: T | ModelDataOnly | PatchUpdate>): { 14 | id?: string; 15 | data: ModelDataOnly; 16 | }; 17 | } 18 | 19 | export const DEFAULT_DOCUMENT_TRANSFORMER: IDocumentTransformer = { 20 | fromFirestoreToObject(data, meta) { 21 | const base: BaseModel = { 22 | id: meta.id, 23 | _rawPath: meta.rawPath, 24 | }; 25 | return Object.assign({}, data, base); 26 | }, 27 | toFirestoreDocument(doc) { 28 | const clone = cloneDeep(doc) as any; 29 | delete clone.id; 30 | 31 | return { 32 | id: (doc as any)['id'] || undefined, 33 | data: clone, 34 | }; 35 | }, 36 | }; 37 | -------------------------------------------------------------------------------- /packages/core/src/storage/types.ts: -------------------------------------------------------------------------------- 1 | declare const t: unique symbol; 2 | export type Id = string & { readonly [t]: T }; 3 | 4 | export interface BaseModel { 5 | id: string; 6 | _rawPath: string; 7 | } 8 | 9 | type NonFunctionPropertyNames = { 10 | [K in keyof T]: T[K] extends Function ? never : K; 11 | }; 12 | type NonFunctionProperties = Pick>; 13 | 14 | type Clonable = { 15 | [K in keyof NonFunctionProperties]: T[K] extends object ? Clonable : T[K]; 16 | }; 17 | 18 | export type ModelDataOnly = Omit; 19 | 20 | export type ModelDataWithId = Pick & ModelDataOnly; 21 | 22 | export type ModelQuery = Partial>; 23 | 24 | export type PatchUpdate = Required> & Omit, 'id'>; 25 | -------------------------------------------------------------------------------- /packages/core/tsconfig.esm2015.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "ES2015", 5 | "outDir": "dist/esm2015", 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig-base.json", 3 | "compilerOptions": { 4 | "rootDir": "./src", 5 | "outDir": "./dist/cjs", 6 | "baseUrl": "./", 7 | "module": "commonjs", 8 | "types": [ 9 | "node", 10 | "mocha", 11 | "reflect-metadata" 12 | ] 13 | }, 14 | "include": ["src/**/*"], 15 | "exclude": ["node_modules"] 16 | } 17 | -------------------------------------------------------------------------------- /packages/core/tsconfig.types.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "emitDeclarationOnly": true, 6 | "outDir": "dist/types", 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/firestore/.env.sample: -------------------------------------------------------------------------------- 1 | GOOGLE_APPLICATION_CREDENTIALS= 2 | -------------------------------------------------------------------------------- /packages/firestore/.env.test: -------------------------------------------------------------------------------- 1 | TZ=utc 2 | NODE_ENV=test 3 | GCLOUD_PROJECT=molzait-local 4 | FIRESTORE_EMULATOR_HOST=127.0.0.1:8082 5 | FIREBASE_AUTH_EMULATOR_HOST=127.0.0.1:9099 6 | FIREBASE_STORAGE_EMULATOR_HOST=127.0.0.1:9199 7 | -------------------------------------------------------------------------------- /packages/firestore/.mocharc.yaml: -------------------------------------------------------------------------------- 1 | timeout: 20000 2 | exit: true 3 | require: 4 | - ts-node/register 5 | - tsconfig-paths/register 6 | -------------------------------------------------------------------------------- /packages/firestore/README.md: -------------------------------------------------------------------------------- 1 | # Firestore Storage 2 | [![Build Status](https://travis-ci.com/freshfox/firestore-storage.svg?branch=master)](https://travis-ci.com/freshfox/firestore-storage) 3 | [![npm version](https://badge.fury.io/js/firestore-storage.svg)](https://badge.fury.io/js/firestore-storage) 4 | [![Dependencies](https://david-dm.org/freshfox/firestore-storage.svg)](https://david-dm.org/freshfox/firestore-storage#info=dependencies) 5 | [![img](https://david-dm.org/freshfox/firestore-storage/dev-status.svg)](https://david-dm.org/freshfox/firestore-storage/#info=devDependencies) 6 | [![Known Vulnerabilities](https://snyk.io/test/github/freshfox/firestore-storage/badge.svg)](https://snyk.io/test/github/freshfox/firestore-storage) 7 | 8 | ## Table of contents 9 | 10 | * [Overview](#overview) 11 | * [Example](#example) 12 | * [Installation](#installation) 13 | * [Tests](#tests) 14 | * [Usage](#usage) 15 | * [Models](#models) 16 | * [Transactions](#transactions) 17 | * [Repository](#repositories) 18 | * [Migrations](#migrations) 19 | * [Typing indexes](#typing-indexes) 20 | 21 | ## Overview 22 | > Typed repositories for Node around Firestore providing a very simple API to 23 | > write and query documents. 24 | 25 | ## Example 26 | ```typescript 27 | // Path is /users/{userId} 28 | const user = await userRepo.save({ 29 | email: 'john@example.com' 30 | }); 31 | 32 | const user = await userRepo.findById({ 33 | userId: 'some-user-id' 34 | }); 35 | 36 | // Path is /restaurants/{restaurantId}/ratings/{ratingId} 37 | const fiveStars = await ratingRepo.list({ 38 | stars: 5 39 | }, { restaurantId: 'some-restaurant-id' }); 40 | ``` 41 | 42 | ## Usage 43 | 44 | ### Installation 45 | ```bash 46 | npm install firestore-storage-core 47 | npm install firestore-storage 48 | ``` 49 | 50 | ### Defining collection paths 51 | Collection paths are defined using the `CollectionPath` class instead of 52 | just a string, to provide detailed information for this path. Such as the 53 | name of the collection and the name of the id. Defining a root collection 54 | path such as `/restaurants/{restaurantId}` will look like: 55 | ```typescript 56 | const RestaurantsCollection = new CollectionPath('restaurants', 'restaurantId'); 57 | ``` 58 | When defining subcollections, the parent collection gets passed as 59 | the third parameter. Currently, generic types have to be passed as well 60 | to have the correct typings in your repositories. 61 | A subcollection such as `/restaurants/{restaurantId}/ratings/{ratingId}` looks like: 62 | ```typescript 63 | const RatingsCollection = new CollectionPath<'ratingId', string, DocumentIds>('ratings', 'ratingId', RestaurantsCollection); 64 | ``` 65 | 66 | ### Creating repositories 67 | 68 | ```typescript 69 | import { BaseRepository } from 'firestore-storage'; 70 | import { BaseModel, Repository} from 'firestore-storage-core'; 71 | 72 | interface Rating extends BaseModel { 73 | stars: number 74 | } 75 | 76 | @Repository({ 77 | path: RatingsCollection 78 | }) 79 | export class RatingsRepository extends BaseRepository { 80 | 81 | } 82 | ``` 83 | 84 | ### Return value conventions for methods 85 | 86 | - `find*()` methods return the document or null when no result was found 87 | - `get*()` methods always return the document and will throw an error when no result was found 88 | - `list*()` methods always return an array and never null or undefined. When no result is found the array is empty 89 | 90 | ## Defining collection paths 91 | 92 | 93 | ## Example 94 | 95 | ```typescript 96 | // Saving data 97 | const restaurant = await restaurantRepo.save({ 98 | name: 'FreshFoods', 99 | address: 'SomeStreet 123', 100 | city: 'New York', 101 | type: 'vegan' 102 | }); 103 | 104 | console.log(restaurant); 105 | /* 106 | { 107 | id: '0vdxYqEisf5vwJLhyLjA', 108 | name: 'FreshFoods', 109 | address: 'SomeStreet 123', 110 | city: 'New York', 111 | type: 'vegan', 112 | }*/ 113 | 114 | // Listing all documents 115 | const allRestaurants = await restaurantRepo.list(); 116 | 117 | // Filtering documents based on attributes 118 | const restaurantsInNewYork = await restaurantRepo.list({ 119 | city: 'New York' 120 | }); 121 | 122 | // More complex queries 123 | const date = new Date('2019-02-01'); 124 | const restaurants = await restaurantRepo.query((qb) => { 125 | return qb 126 | .where('openDate', '<=', date) 127 | .orderBy('openDate', 'asc'); 128 | }); 129 | ``` 130 | 131 | 132 | ### Running tests on your firestore.rules 133 | 134 | This packages provides utilities to run tests against your Firestore rules. 135 | You need the `@firebase/testing` package and the local emulator installed. 136 | To install the emulator run `$ firebase setup:emulators:firestore` 137 | 138 | Below is an example of how to run tests against the rules. Create a new instance 139 | of FirestoreRuleTest for each test. Add some test data, load the rules and run your assertions. 140 | The constructor of `FirestoreRuleTest` takes the uid of the authenticated user as an argument. 141 | This will be the `request.auth.uid` property which you can read in your rules. 142 | Passing no uid will send unauthenticated requests to the emulator. 143 | 144 | ```typescript 145 | import {FirestoreRuleTest} from 'firestore-storage'; 146 | import * as firebase from "@firebase/testing"; 147 | 148 | describe('Rules', function () { 149 | 150 | const pathToRules = `${__dirname}/../../../../firestore.rules`; 151 | 152 | before(async () => { 153 | await FirestoreRuleTest.start(); 154 | }); 155 | 156 | after(async () => { 157 | await FirestoreRuleTest.stop(); 158 | }); 159 | 160 | describe('Unauthenticated', function () { 161 | 162 | it('should not be able to read from users', async () => { 163 | const tc = new FirestoreRuleTest(); 164 | const userId = 'alice'; 165 | const userDoc = tc.firestore.collection('users').doc(userId); 166 | await userDoc.set({}); 167 | await tc.loadRules(pathToRules); 168 | await firebase.assertFails(userDoc.get()) 169 | }); 170 | }); 171 | 172 | describe('Authenticated', function () { 173 | 174 | it('should not be able to read reservations from different account', async () => { 175 | 176 | const userId1 = 'alice'; 177 | const accountId1 = `account-${userId1}`; 178 | const userId2 = 'bob'; 179 | const accountId2 = `account-${userId2}`; 180 | 181 | 182 | const tc = new FirestoreRuleTest(userId1); 183 | const userDoc1 = tc.firestore.collection('users').doc(userId1); 184 | const userDoc2 = tc.firestore.collection('users').doc(userId2); 185 | 186 | await userDoc1.set({accountId: accountId1}); 187 | await userDoc2.set({accountId: accountId2}); 188 | 189 | const resColl1 = tc.firestore.collection('accounts').doc(accountId1).collection('reservations'); 190 | const resColl2 = tc.firestore.collection('accounts').doc(accountId2).collection('reservations'); 191 | 192 | await resColl1.add({}); 193 | await resColl2.add({}); 194 | 195 | await tc.loadRules(pathToRules); 196 | 197 | await firebase.assertSucceeds(resColl1.get()); 198 | await firebase.assertFails(resColl2.get()); 199 | 200 | }); 201 | 202 | }); 203 | 204 | }); 205 | ``` 206 | 207 | Since those values are not in the document itself, they will be added to the 208 | returning object when reading from Firestore. You can pass objects with those attributes to 209 | the `save()` function. They will always be omitted and the id will be used as the document id 210 | when writing data. 211 | 212 | ## Transactions 213 | 214 | Each repository as well as the FirestoreStorage and MemoryStorage implementations 215 | provide a [transaction()](#transaction) function. 216 | 217 | ## Repositories 218 | 219 | Create repository classes for each collection you want to query documents from. For example 220 | if you want to query documents to query from the `users` collection you create a class `UserRepository` extending `BaseRepository`. 221 | Each repository provides a list of functions for saving, querying and deleting documents 222 | and you can extend each repository based on your needs. 223 | 224 | When extending `BaseRepository` you have to implement the function 225 | `getCollectionPath(...ids: string[])`. For root collections the ids[] will be empty. 226 | For sub-collections this parameter will contain an hierarchically ordered list of parent 227 | document ids. 228 | 229 | Each function takes multiple ids as its last arguments. Those are the hierarchically 230 | ordered list of parent document ids passed to the `getCollectionPath(...)` function. 231 | 232 | The following examples are based on the `RestaurantRepository` and `ReviewRepository` 233 | created [below](#extending-baserepository) 234 | 235 | ### findById 236 | Takes a hierarchical ordered list of document ids. Returns the document when found or `null` 237 | ```typescript 238 | const review = await reviewRepo.findById(restaurantId, reviewId); 239 | ``` 240 | 241 | ### find 242 | Queries the collection to match the given arguments, returns the first result or `null` if none is found. 243 | ```typescript 244 | const review = await reviewRepo.find({ 245 | rating: 5 246 | }, restaurantId); 247 | ``` 248 | 249 | ### getById 250 | Works exactly like [findById](#findbyid) but [throws an error](#custom-error) if no document was found 251 | 252 | ### get 253 | Works exactly like [find](#find) but [throws an error](#custom-error) if no document was found 254 | 255 | ### list 256 | Query a list of documents with a set of given arguments. This function always returns an array. If no results were found 257 | the array will be empty 258 | 259 | ```typescript 260 | const allOneStarRatings = await reviewRepo.list({ 261 | rating: 1 262 | }, restaurantId); 263 | ``` 264 | 265 | ### query 266 | Do more complex queries like `greater than` and `lower than` comparisons. 267 | ```typescript 268 | const reviews = await reviewRepo.query(() => { 269 | return qb 270 | .where('rating', '==', 2) 271 | .where('submitDate', '<', new Date('2019-12-31')); 272 | }, restaurantId); 273 | ``` 274 | Valid operators are `==` | `<` | `<=` | `>` | `>=` 275 | 276 | #### QueryBuilder functions 277 | 278 | ``` 279 | qb.where(fieldName, operator, value) 280 | qb.orderBy(fieldName, direction) // 'asc' or 'desc' 281 | qb.offset(number) 282 | qb.limit(number) 283 | ``` 284 | 285 | ### findAll 286 | Returns an array of documents for a given array of ids. The array will contain null values if some documents aren't found 287 | ```typescript 288 | const r = await restaurantRepo.findAll([id1, id2]); 289 | ``` 290 | 291 | ### getAll 292 | Returns an array of documents for a given array of ids. The array won't contain null values. If a document doesn't exists, 293 | an error will be thrown 294 | ```typescript 295 | const r = await restaurantRepo.getAll([id1, id2]); 296 | ``` 297 | 298 | ### save 299 | Saves a document into Firestore. 300 | ```typescript 301 | const restaurant = await restaurantRepo.save({ 302 | name: 'Ebi' 303 | }); 304 | ``` 305 | If you want to update data you just have to pass the id of the document. 306 | ```typescript 307 | const user = await restaurantRepo.save({ 308 | id: '8zCW4UszD0wmdrpBNswp', 309 | name: 'Ebi', 310 | openDate: new Date() 311 | }); 312 | ``` 313 | By default this will create the document with this id if it doesn't exist 314 | or merge the properties into the existing document. If you want to write a document 315 | and instead of don't merge use the [write()][write] function 316 | 317 | ### write 318 | Sets the passed data. If the document exists it will be overwritten. 319 | ```typescript 320 | const user = await restaurantRepo.write({ 321 | name: 'FreshBurgers', 322 | openDate: new Date() 323 | }); 324 | ``` 325 | 326 | ### delete 327 | Deletes a document by a given id 328 | ```typescript 329 | // For a nested collection 330 | await reviewRepo.delete(restaurantId, reviewId); 331 | // For a root level collection 332 | await restaurantRepo.delete(restaurantId); 333 | ``` 334 | 335 | ### transaction 336 | Takes an update function and an array of ids. Find more about transactions at the 337 | [Firestore documentation][transaction-doc] 338 | ```typescript 339 | const result = await restaurantRepo.transaction((trx) => { 340 | const u = trx.get('some-id'); 341 | u.name = 'Burger Store'; 342 | trx.set(u); 343 | return 'done'; 344 | }) 345 | ``` 346 | 347 | ### Extending BaseRepository 348 | 349 | ```typescript 350 | export class RestaurantRepository extends BaseRepository { 351 | 352 | getCollectionPath(...documentIds: string[]): string { 353 | return 'restaurants'; 354 | } 355 | } 356 | ``` 357 | 358 | When creating repositories for nested collection it's always a good idea to check if the correct ids are passed into 359 | `getCollectionPath(...)`. 360 | 361 | ```typescript 362 | export class ReviewRepository extends BaseRepository { 363 | 364 | getCollectionPath(...documentIds): string { 365 | const id = documentIds.shift(); 366 | if (!id) { 367 | throw new Error('RestaurantId id is missing'); 368 | } 369 | return `restaurants/${id}/reviews`; 370 | } 371 | } 372 | ``` 373 | 374 | This will throw an error when trying to save or query without passing the user id. 375 | ```typescript 376 | await reviewRepo.save({...}); // Throws and error 377 | await reviewRepo.save({...}, ''); // Succeeds 378 | ``` 379 | 380 | ## Migrations 381 | 382 | This package provides a base class to migrate data in Firestore. 383 | For more info look at [this example](packages/firestore/src/test/storage/migrations_test.ts) 384 | 385 | ## Typing indexes 386 | Use the `IndexManager` class to build your index structure and the provided `fss` 387 | script to generate the `firestore.indexes.json`. Look at `src/test/storage/index_manager_example.ts` 388 | to see how to use the `IndexManager`. Then run: 389 | ```bash 390 | $ fss generate:index 391 | ``` 392 | The `fss` script gets added as a script to your `node_modules` 393 | 394 | ## Custom error 395 | The query functions [get](#get) and [getById](#getbyid) will throw an error if the document doesn't exist. 396 | If you want to throw an custom error you can do that by passing an error factory. 397 | ```typescript 398 | export class HttpError extends Error { 399 | constructor(msg: string, public code: number) { 400 | super(msg) 401 | } 402 | } 403 | 404 | const errorFactory = (msg) => { 405 | return new HttpError(msg, 404); 406 | }; 407 | 408 | ``` 409 | 410 | ### Using Inversify 411 | ```typescript 412 | FirestoreStorageModule.createWithFirestore(admin.firestore(), errorFactory) 413 | ``` 414 | 415 | ### Using vanilla Typescript 416 | ```typescript 417 | class RestaurantRepository extends BaseRepository { 418 | 419 | constructor() { 420 | super(storage, errorFactory); 421 | } 422 | } 423 | ``` 424 | 425 | [inversify]: http://inversify.io/ 426 | [transaction-doc]: https://firebase.google.com/docs/firestore/manage-data/transactions 427 | -------------------------------------------------------------------------------- /packages/firestore/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "firestore-storage", 3 | "version": "7.1.0", 4 | "description": "A typed wrapper around Firestore including a query-builder and an in-memory implementation for testing", 5 | "main": "dist/lib/index.js", 6 | "types": "dist/lib/index.d.ts", 7 | "keywords": [ 8 | "firestore", 9 | "typescript", 10 | "memory", 11 | "querybuilder" 12 | ], 13 | "directories": { 14 | "test": "test", 15 | "lib": "src" 16 | }, 17 | "files": [ 18 | "src/lib", 19 | "dist/lib", 20 | "dist/test/rules", 21 | "README.md", 22 | "package.json" 23 | ], 24 | "engines": { 25 | "node": ">=10.10.0" 26 | }, 27 | "scripts": { 28 | "test:ts": "NODE_ENV=test mocha './src/**/*.test.ts'", 29 | "test": "pnpm firebase emulators:exec --only firestore --project firestore-storage-local 'pnpm test:ts --reporter mocha-junit-reporter'", 30 | "build": "pnpm clean && tsc --build", 31 | "clean": "rm -rf dist tsconfig.tsbuildinfo", 32 | "firebase": "firebase" 33 | }, 34 | "repository": { 35 | "type": "git", 36 | "url": "git@github.com:freshfox/firestore-storage.git" 37 | }, 38 | "author": "Dominic Bartl", 39 | "license": "MIT", 40 | "homepage": "https://github.com/freshfox/firestore-storage", 41 | "dependencies": { 42 | "@google-cloud/firestore": "^6.5.0" 43 | }, 44 | "devDependencies": { 45 | "@firebase/rules-unit-testing": "^2.0.7", 46 | "@types/mocha": "^9.0.0", 47 | "@types/node": "^14.11.8", 48 | "firestore-storage-core": "^7.0.3", 49 | "mocha": "^9.1.3", 50 | "node-env-file": "^0.1.8", 51 | "should": "^13.2.3", 52 | "ts-node": "^10.4.0", 53 | "tsconfig-paths": "^4.2.0", 54 | "typescript": "^4.0.3" 55 | }, 56 | "peerDependencies": { 57 | "firestore-storage-core": ">=6.0.0" 58 | }, 59 | "gitHead": "0b06debcfa978dcfd12f74599cded3802179e34b" 60 | } 61 | -------------------------------------------------------------------------------- /packages/firestore/scripts/prepare: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | yarn firebase setup:emulators:firestore 5 | echo "Writing credentials to ${GOOGLE_APPLICATION_CREDENTIALS}" 6 | echo $GOOGLE_APPLICATION_CREDENTIALS_BASE64 | base64 -d > $GOOGLE_APPLICATION_CREDENTIALS 7 | -------------------------------------------------------------------------------- /packages/firestore/src/lib/index.ts: -------------------------------------------------------------------------------- 1 | export * from './storage/export'; 2 | export * from './storage/migrations'; 3 | export * from './storage/query'; 4 | export * from './storage/repository'; 5 | export * from './storage/transaction'; 6 | -------------------------------------------------------------------------------- /packages/firestore/src/lib/storage/definitions.test.ts: -------------------------------------------------------------------------------- 1 | import { Timestamp } from '@google-cloud/firestore'; 2 | import { BaseRepository } from '../../lib'; 3 | import { BaseModel, CollectionPath, DocumentIds, Id, Repository } from 'firestore-storage-core'; 4 | 5 | export interface Model extends BaseModel { 6 | details?: { 7 | field1?: { 8 | subField1?: string; 9 | subField2?: string; 10 | }; 11 | field2?: string; 12 | }; 13 | someIds?: string[]; 14 | startTime?: Timestamp; 15 | endTime?: Timestamp; 16 | } 17 | 18 | const GenericModelPath = new CollectionPath('genericModels', 'modelId'); 19 | @Repository({ 20 | path: GenericModelPath, 21 | }) 22 | export class ModelRepository extends BaseRepository {} 23 | 24 | export type AccountId = Id<'Account'>; 25 | export type UserId = Id<'User'>; 26 | 27 | export interface Account extends BaseModel { 28 | id: AccountId; 29 | name: string; 30 | } 31 | 32 | export interface User extends BaseModel { 33 | id: UserId; 34 | userName: string; 35 | lastSignIn?: Timestamp; 36 | address?: { 37 | street?: string; 38 | zip?: string; 39 | city?: string; 40 | }; 41 | } 42 | 43 | export const AccountsPath = new CollectionPath<'accountId', AccountId>('accounts', 'accountId'); 44 | export const UsersPath = new CollectionPath<'userId', UserId, DocumentIds>( 45 | 'users', 46 | 'userId', 47 | AccountsPath 48 | ); 49 | 50 | @Repository({ 51 | path: AccountsPath, 52 | }) 53 | export class AccountRepository extends BaseRepository {} 54 | 55 | @Repository({ 56 | path: UsersPath, 57 | }) 58 | export class UserRepository extends BaseRepository {} 59 | -------------------------------------------------------------------------------- /packages/firestore/src/lib/storage/export.ts: -------------------------------------------------------------------------------- 1 | import * as firestore from '@google-cloud/firestore'; 2 | import * as path from 'path'; 3 | 4 | export async function exportFirestore(projectId: string, bucketName: string, dir: string, collectionIds?: string[]) { 5 | const client = new firestore.v1.FirestoreAdminClient(); 6 | const databaseName = client.databasePath(projectId, '(default)'); 7 | 8 | return client 9 | .exportDocuments({ 10 | name: databaseName, 11 | outputUriPrefix: `gs://${path.join(bucketName, dir)}`, 12 | // Leave collectionIds empty to export all collections 13 | // or set to a list of collection IDs to export, 14 | // collectionIds: ['users', 'posts'] 15 | collectionIds: collectionIds || [], 16 | }) 17 | .then((responses) => { 18 | const response = responses[0]; 19 | console.log(`Operation Name: ${response['name']}`); 20 | }) 21 | .catch((err) => { 22 | console.error(err); 23 | throw new Error('Export operation failed'); 24 | }); 25 | } 26 | -------------------------------------------------------------------------------- /packages/firestore/src/lib/storage/migrations.test.ts: -------------------------------------------------------------------------------- 1 | import 'should'; 2 | import { Firestore } from '@google-cloud/firestore'; 3 | import { Migrations } from './migrations'; 4 | import { AccountRepository } from './definitions.test'; 5 | import { createFirestoreTests } from './test-utils'; 6 | 7 | describe('Migrations', function () { 8 | class MyProjectMigrations extends Migrations { 9 | public readonly accountRepo: AccountRepository; 10 | 11 | constructor(firestore: Firestore) { 12 | super(firestore); 13 | this.accountRepo = new AccountRepository(firestore); 14 | } 15 | 16 | getVersion(): number { 17 | return 1; 18 | } 19 | 20 | onUpgrade(toVersion: number) { 21 | switch (toVersion) { 22 | case 1: 23 | return this.appendToName(); 24 | } 25 | } 26 | 27 | private async appendToName() { 28 | const accounts = await this.accountRepo.list(null); 29 | for (const account of accounts) { 30 | await this.accountRepo.update({ 31 | id: account.id, 32 | name: `${account.name}-1`, 33 | }); 34 | } 35 | } 36 | } 37 | 38 | let migrations: MyProjectMigrations; 39 | createFirestoreTests(this, (firestore) => { 40 | migrations = new MyProjectMigrations(firestore); 41 | }); 42 | 43 | it('should run a simple migration', async () => { 44 | await migrations.readVersion().should.resolvedWith(0); 45 | let a1 = await migrations.accountRepo.save({ 46 | name: 'acc1', 47 | }); 48 | let a2 = await migrations.accountRepo.save({ 49 | name: 'acc2', 50 | }); 51 | await migrations.upgrade(); 52 | 53 | a1 = await migrations.accountRepo.getById({ accountId: a1.id }); 54 | a2 = await migrations.accountRepo.getById({ accountId: a2.id }); 55 | 56 | a1.name.should.eql('acc1-1'); 57 | a2.name.should.eql('acc2-1'); 58 | await migrations.readVersion().should.resolvedWith(1); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /packages/firestore/src/lib/storage/migrations.ts: -------------------------------------------------------------------------------- 1 | import { DocumentReference, Firestore } from '@google-cloud/firestore'; 2 | 3 | export abstract class Migrations { 4 | protected constructor(protected storage: Firestore) {} 5 | 6 | abstract getVersion(): number; 7 | 8 | abstract onUpgrade(toVersion: number): Promise; 9 | 10 | async upgrade() { 11 | let version = await this.readVersion(); 12 | const targetVersion = this.getVersion(); 13 | console.log(`Current database version (${version}). Target version (${targetVersion})`); 14 | while (version < targetVersion) { 15 | console.log(`Upgrading from ${version} to ${version + 1}`); 16 | version++; 17 | const label = `Successfully upgraded to ${version}`; 18 | console.time(label); 19 | await this.onUpgrade(version); 20 | await this.writeVersion(version); 21 | console.timeEnd(label); 22 | } 23 | } 24 | 25 | async readVersion(): Promise { 26 | const data = await this.getDocumentReference().get(); 27 | return data.data()?.version || 0; 28 | } 29 | 30 | async writeVersion(version: number) { 31 | await this.getDocumentReference().set( 32 | { 33 | version: version, 34 | }, 35 | { merge: true } 36 | ); 37 | } 38 | 39 | // noinspection JSMethodCanBeStatic 40 | protected getVersionDocumentPath() { 41 | return 'version/current'; 42 | } 43 | 44 | private getDocumentReference(): DocumentReference<{ version: number }> { 45 | return this.storage.doc(this.getVersionDocumentPath()) as DocumentReference<{ version: number }>; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /packages/firestore/src/lib/storage/query.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CollectionGroup, 3 | CollectionReference, 4 | OrderByDirection, 5 | QuerySnapshot, 6 | WhereFilterOp, 7 | Query as FSQuery, 8 | } from '@google-cloud/firestore'; 9 | import { BaseQuery, BaseModel, WhereProp } from 'firestore-storage-core'; 10 | 11 | export class Query extends BaseQuery>> { 12 | constructor(private base: CollectionReference | CollectionGroup | FSQuery) { 13 | super(); 14 | } 15 | 16 | protected applyWhere(key: string, operator: FirebaseFirestore.WhereFilterOp, value: any) { 17 | this.base = this.base.where(key, operator, value); 18 | return this; 19 | } 20 | 21 | orderBy(prop: WhereProp, direction: OrderByDirection) { 22 | this.base = this.base.orderBy(this.getWhereProp(prop), direction); 23 | return this; 24 | } 25 | 26 | limit(limit: number) { 27 | this.base = this.base.limit(limit); 28 | return this; 29 | } 30 | 31 | offset(offset: number) { 32 | this.base = this.base.offset(offset); 33 | return this; 34 | } 35 | 36 | execute(): Promise> { 37 | return this.base.get() as any; 38 | } 39 | 40 | count() { 41 | return this.base.count(); 42 | } 43 | 44 | getQuery() { 45 | return this.base; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /packages/firestore/src/lib/storage/repository.test.ts: -------------------------------------------------------------------------------- 1 | import 'should'; 2 | import { FirestoreStorageError } from 'firestore-storage-core'; 3 | import { AccountId, AccountRepository, ModelRepository, UserRepository } from './definitions.test'; 4 | import { createFirestoreTests } from './test-utils'; 5 | 6 | describe('Repository', function () { 7 | let modelRepo: ModelRepository; 8 | let accountRepo: AccountRepository; 9 | let userRepo: UserRepository; 10 | 11 | createFirestoreTests(this, (firestore) => { 12 | modelRepo = new ModelRepository(firestore); 13 | userRepo = new UserRepository(firestore); 14 | accountRepo = new AccountRepository(firestore); 15 | }); 16 | 17 | describe('#findById()', function () { 18 | it('should return null for non existing document', async () => { 19 | await accountRepo.findById({ accountId: 'acc' as AccountId }).should.resolvedWith(null); 20 | }); 21 | 22 | it('should return document', async () => { 23 | const acc = await accountRepo.save({ name: 'acc' }); 24 | await accountRepo.findById({ accountId: acc.id }).should.resolvedWith(acc); 25 | }); 26 | }); 27 | 28 | describe('#getById()', function () { 29 | it('should throw a FirestoreStorageError when a document was not found ', async () => { 30 | const err = await accountRepo.getById({ accountId: 'acc' as AccountId }).should.rejected(); 31 | err.should.instanceof(FirestoreStorageError); 32 | }); 33 | }); 34 | 35 | describe('#findAll()', function () { 36 | it('should find a set of documents', async () => { 37 | const ids = { 38 | accountId: 'acc1' as AccountId, 39 | }; 40 | const u1 = await userRepo.save({ userName: 'U1' }, ids); 41 | const u2 = await userRepo.save({ userName: 'U2' }, ids); 42 | 43 | const users = await userRepo.findAll([u1.id, u2.id], ids); 44 | users.should.eql([u1, u2]); 45 | }); 46 | 47 | it("should return null if a document doesn't exist", async () => { 48 | const accounts = await accountRepo.findAll(['id']); 49 | accounts.should.eql([null]); 50 | }); 51 | }); 52 | 53 | describe('#query()', function () { 54 | it('should query documents based on sub-field', async () => { 55 | const ids = { 56 | accountId: 'acc1' as AccountId, 57 | }; 58 | 59 | const u1 = await userRepo.save({ userName: 'u1', address: { city: 'Vienna' } }, ids); 60 | const u2 = await userRepo.save({ userName: 'u2', address: { city: 'Vienna' } }, ids); 61 | const u3 = await userRepo.save({ userName: 'u3', address: { city: 'Berlin' } }, ids); 62 | 63 | const users = await userRepo.query((qb) => { 64 | return qb.where((u) => u.address.city, '==', 'Vienna'); 65 | }, ids); 66 | 67 | users.should.length(2); 68 | users.find((u) => u.id === u1.id).should.not.undefined(); 69 | users.find((u) => u.id === u2.id).should.not.undefined(); 70 | }); 71 | }); 72 | 73 | describe('#save()', function () { 74 | it('should save a document', async () => { 75 | const account = await accountRepo.save({ 76 | name: 'acc', 77 | }); 78 | account.name.should.eql('acc'); 79 | account.id.should.type('string').not.empty(); 80 | }); 81 | 82 | it('should update an existing document', async () => { 83 | const account = await accountRepo.save({ 84 | name: 'acc', 85 | }); 86 | 87 | await accountRepo.save({ 88 | id: account.id, 89 | name: 'acc2', 90 | }); 91 | const accounts = await accountRepo.list(null); 92 | 93 | accounts.should.length(1); 94 | accounts[0].name.should.eql('acc2'); 95 | }); 96 | }); 97 | 98 | describe('#write()', function () { 99 | it('should save a document without merging data', async () => { 100 | const ids = { accountId: 'acc' as AccountId }; 101 | const u1 = await userRepo.write( 102 | { 103 | userName: 'john', 104 | address: { city: 'Vienna' }, 105 | }, 106 | ids 107 | ); 108 | const u2 = await userRepo.write( 109 | { 110 | id: u1.id, 111 | userName: 'john2', 112 | }, 113 | ids 114 | ); 115 | 116 | u2.should.property('id', u1.id); 117 | u2.should.property('userName', 'john2'); 118 | u2.should.not.property('address'); 119 | }); 120 | }); 121 | 122 | describe('#update()', function () { 123 | it('should throw an error when trying to update a document', async () => { 124 | const err = await accountRepo.update({ id: 'acc' as AccountId, name: 'acc' }).should.rejected(); 125 | err.should.instanceof(FirestoreStorageError); 126 | }); 127 | 128 | it('should successfully update an existing document', async () => { 129 | let acc = await accountRepo.save({ name: 'acc' }); 130 | acc = await accountRepo.update({ 131 | id: acc.id, 132 | name: 'acc2', 133 | }); 134 | acc.should.property('name', 'acc2'); 135 | }); 136 | 137 | it('should check for 1 level merge', async () => { 138 | const accountId = 'acc1' as AccountId; 139 | let user = await userRepo.create({ userName: 'name' }, { accountId }); 140 | user = await userRepo.update( 141 | { 142 | id: user.id, 143 | address: { 144 | street: 'street', 145 | }, 146 | }, 147 | { accountId } 148 | ); 149 | user.should.eql({ 150 | id: user.id, 151 | userName: 'name', 152 | address: { 153 | street: 'street', 154 | }, 155 | _rawPath: user._rawPath, 156 | }); 157 | }); 158 | 159 | it('should check for 2 level merge', async () => { 160 | const accountId = 'acc1' as AccountId; 161 | let user = await userRepo.create( 162 | { 163 | userName: 'name', 164 | address: { 165 | street: 'street', 166 | }, 167 | }, 168 | { accountId } 169 | ); 170 | user = await userRepo.update( 171 | { 172 | id: user.id, 173 | address: { 174 | city: 'Vienna', 175 | }, 176 | }, 177 | { accountId } 178 | ); 179 | user.should.eql({ 180 | id: user.id, 181 | userName: 'name', 182 | address: { 183 | city: 'Vienna', 184 | }, 185 | _rawPath: user._rawPath, 186 | }); 187 | }); 188 | }); 189 | }); 190 | -------------------------------------------------------------------------------- /packages/firestore/src/lib/storage/repository.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BaseRepository as CoreBaseRepository, 3 | CollectionIds, 4 | CollectionPath, 5 | DocumentIds, 6 | BaseModel, 7 | ModelQuery, 8 | PatchUpdate, 9 | ModelDataOnly, 10 | FirestoreStorageError, 11 | ModelDataWithId, 12 | } from 'firestore-storage-core'; 13 | import { Query } from './query'; 14 | import { DocumentReference, DocumentSnapshot, Firestore } from '@google-cloud/firestore'; 15 | import { applyToDoc } from './utils'; 16 | import { IDocumentTransformer } from 'firestore-storage-core/dist/cjs'; 17 | 18 | export abstract class BaseRepository< 19 | T extends BaseModel, 20 | Path extends CollectionPath 21 | > extends CoreBaseRepository> { 22 | constructor(protected firestore: Firestore) { 23 | super(); 24 | } 25 | 26 | fromFirestoreToObject(snapshot: DocumentSnapshot) { 27 | if (!snapshot.exists) { 28 | return null; 29 | } 30 | const transformer: IDocumentTransformer = this.getTransformer(); 31 | return transformer.fromFirestoreToObject(snapshot.data() as ModelDataOnly, { 32 | id: snapshot.id, 33 | rawPath: snapshot.ref.path, 34 | }); 35 | } 36 | 37 | /** 38 | * 39 | */ 40 | async findById(ids: DocumentIds): Promise { 41 | const doc = await this.firestore.doc(this.getDocumentPath(ids)).get(); 42 | return this.fromFirestoreToObject(doc as any); 43 | } 44 | 45 | async getById(ids: DocumentIds): Promise { 46 | const doc = await this.findById(ids); 47 | if (doc) { 48 | return doc; 49 | } 50 | throw new FirestoreStorageError(this.getPath().path(), ids); 51 | } 52 | 53 | async find(attributes: ModelQuery, ids: CollectionIds): Promise { 54 | const documents = await this.list(attributes, ids); 55 | return documents[0] || null; 56 | } 57 | 58 | async get(attributes: ModelQuery, ids: CollectionIds): Promise { 59 | const doc = await this.find(attributes, ids); 60 | if (doc) { 61 | return doc; 62 | } 63 | throw new FirestoreStorageError(this.getPath().path(), ids); 64 | } 65 | 66 | list(attributes: ModelQuery | null, ids: CollectionIds): Promise { 67 | return this.query((q) => { 68 | return q.whereAll(attributes); 69 | }, ids); 70 | } 71 | 72 | async query(cb: (qb: Query) => Query, ids: CollectionIds): Promise { 73 | const path = this.getCollectionPath(ids); 74 | const query = new Query(this.firestore.collection(path)); 75 | const result = await cb(query).execute(); 76 | if (result.empty) { 77 | return []; 78 | } 79 | return result.docs.map((doc) => { 80 | return this.fromFirestoreToObject(doc)!; 81 | }); 82 | } 83 | 84 | async groupQuery(cb?: (qb: Query) => Query): Promise { 85 | const group = this.firestore.collectionGroup(this.getCollectionName()); 86 | const result = await (cb ? cb(new Query(group)).execute() : group.get()); 87 | if (result.empty) { 88 | return []; 89 | } 90 | return result.docs.map((doc) => { 91 | return this.fromFirestoreToObject(doc as any)!; 92 | }); 93 | } 94 | 95 | async findAll(documentIds: string[], ids: CollectionIds): Promise<(T | null)[]> { 96 | if (documentIds.length === 0) { 97 | return []; 98 | } 99 | 100 | const path = this.getCollectionPath(ids); 101 | const docRefs: DocumentReference[] = documentIds.map((id) => { 102 | return this.firestore.collection(path).doc(id); 103 | }); 104 | const result = await this.firestore.getAll(...docRefs); 105 | return result.map((document) => { 106 | return this.fromFirestoreToObject(document as any); 107 | }); 108 | } 109 | 110 | async getAll(documentIds: string[], ids: CollectionIds): Promise { 111 | const all = await this.findAll(documentIds, ids); 112 | for (const id of documentIds) { 113 | const doc = all.find((d) => d?.id === id); 114 | if (!doc) { 115 | throw new FirestoreStorageError(this.getPath().path(), ids); 116 | } 117 | } 118 | return all; 119 | } 120 | 121 | async count(cb: (qb: Query) => Query, ids: CollectionIds) { 122 | const path = this.getCollectionPath(ids); 123 | const query = new Query(this.firestore.collection(path)); 124 | const result = await cb(query).count().get(); 125 | return result.data().count; 126 | } 127 | 128 | async create(data: T | ModelDataOnly, ids: CollectionIds): Promise { 129 | return this.applyToDocRef(data, ids, (doc, data) => { 130 | return doc.create(data); 131 | }); 132 | } 133 | 134 | /**@deprecated use upsert instead*/ 135 | async save(data: T | ModelDataOnly | PatchUpdate>, ids: CollectionIds): Promise { 136 | return this.applyToDocRef(data, ids, (doc, data) => { 137 | return doc.set(data, { 138 | merge: true, 139 | }); 140 | }); 141 | } 142 | 143 | async upsert(data: T | ModelDataOnly, ids: CollectionIds): Promise { 144 | return this.applyToDocRef(data, ids, (doc, data) => { 145 | return doc.set(data, { 146 | merge: true, 147 | }); 148 | }); 149 | } 150 | 151 | async write(data: T | ModelDataOnly, ids: CollectionIds): Promise { 152 | return this.applyToDocRef(data, ids, (doc, data) => { 153 | return doc.set(data, { 154 | merge: false, 155 | }); 156 | }); 157 | } 158 | 159 | async update(data: T | PatchUpdate>, ids: CollectionIds): Promise { 160 | return this.applyToDocRef(data, ids, (doc, data) => { 161 | return doc.update(data); 162 | }); 163 | } 164 | 165 | private async applyToDocRef( 166 | data: T | ModelDataOnly | PatchUpdate>, 167 | ids: CollectionIds, 168 | cb: (doc: DocumentReference, data: ModelDataOnly) => Promise 169 | ) { 170 | return applyToDoc(this.firestore, this, data, ids, async (id, data, docRef) => { 171 | try { 172 | await cb(docRef, data); 173 | } catch (err) { 174 | throw new FirestoreStorageError(this.getPath().path(), ids, err.message); 175 | } 176 | const doc = await docRef.get(); 177 | return this.fromFirestoreToObject(doc as DocumentSnapshot); 178 | }); 179 | } 180 | 181 | async delete(ids: DocumentIds): Promise { 182 | const path = this.getDocumentPath(ids); 183 | await this.firestore.doc(path).delete(); 184 | } 185 | 186 | generateId() { 187 | return this.firestore.collection('any').doc().id; 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /packages/firestore/src/lib/storage/test-utils.ts: -------------------------------------------------------------------------------- 1 | import * as env from 'node-env-file'; 2 | import * as fs from 'fs'; 3 | import { Firestore } from '@google-cloud/firestore'; 4 | import { initializeTestEnvironment, RulesTestEnvironment } from '@firebase/rules-unit-testing'; 5 | 6 | const path = __dirname + '/../../../.env'; 7 | if (fs.existsSync(path)) { 8 | env(path); 9 | } 10 | 11 | export function createFirestoreTests(context: Mocha.Suite, setup: (firestore: Firestore) => any) { 12 | let app: RulesTestEnvironment; 13 | 14 | context.beforeEach(async () => { 15 | app = await initializeTestEnvironment({ 16 | projectId: 'firestore-storage-local', 17 | firestore: { 18 | host: '127.0.0.1', 19 | port: 8080, 20 | }, 21 | }); 22 | await app.clearFirestore(); 23 | const firestore = new Firestore({ 24 | projectId: app.projectId, 25 | host: app.emulators.firestore.host, 26 | port: app.emulators.firestore.port, 27 | ssl: false, 28 | }); 29 | await setup(firestore); 30 | }); 31 | 32 | context.afterEach(async () => { 33 | if (app) { 34 | await app.cleanup(); 35 | } 36 | }); 37 | } 38 | -------------------------------------------------------------------------------- /packages/firestore/src/lib/storage/transaction.test.ts: -------------------------------------------------------------------------------- 1 | import { Account, AccountRepository, AccountsPath, ModelRepository, UserRepository } from './definitions.test'; 2 | import { createFirestoreTests } from './test-utils'; 3 | import { runFirestoreTransaction } from './transaction'; 4 | import { Firestore } from '@google-cloud/firestore'; 5 | import 'should'; 6 | 7 | describe('Transaction', function () { 8 | let firestore: Firestore; 9 | let modelRepo: ModelRepository; 10 | let accountRepo: AccountRepository; 11 | let userRepo: UserRepository; 12 | 13 | createFirestoreTests(this, (f) => { 14 | firestore = f; 15 | modelRepo = new ModelRepository(firestore); 16 | userRepo = new UserRepository(firestore); 17 | accountRepo = new AccountRepository(firestore); 18 | }); 19 | 20 | it('should create a document', async () => { 21 | const acc = await accountRepo.create({ 22 | name: 'Test', 23 | }); 24 | 25 | await runFirestoreTransaction(firestore, async (trx) => { 26 | const a = await trx.getById(accountRepo, { accountId: acc.id }); 27 | trx.create( 28 | accountRepo, 29 | { 30 | name: a.name + '2', 31 | }, 32 | undefined 33 | ); 34 | }); 35 | }); 36 | 37 | it('should query documents', async () => { 38 | const create = async (name: string) => { 39 | const acc = await accountRepo.create({ 40 | name, 41 | }); 42 | return acc.id; 43 | }; 44 | 45 | const prefix = `acc-${Date.now()}`; 46 | 47 | const [a1, a2, a3, a4] = await Promise.all([ 48 | create(prefix + 'Test1'), 49 | create(prefix + 'Test2'), 50 | create(prefix + 'Test3'), 51 | create(prefix + 'Test1'), 52 | ]); 53 | 54 | const ids = await runFirestoreTransaction(firestore, async (trx) => { 55 | const accounts = await trx.query( 56 | accountRepo, 57 | (qb) => { 58 | return qb.whereAll({ 59 | name: prefix + 'Test1', 60 | }); 61 | }, 62 | undefined 63 | ); 64 | return accounts.map((a) => a.id); 65 | }); 66 | ids.sort().should.eql([a1, a4].sort()); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /packages/firestore/src/lib/storage/transaction.ts: -------------------------------------------------------------------------------- 1 | import { DocumentReference, DocumentSnapshot, Firestore, Transaction } from '@google-cloud/firestore'; 2 | import { 3 | ModelDataOnly, 4 | BaseModel, 5 | CollectionPath, 6 | CollectionIds, 7 | FirestoreStorageError, 8 | DocumentIds, 9 | PatchUpdate, 10 | ModelDataWithId, 11 | } from 'firestore-storage-core'; 12 | import { BaseRepository } from './repository'; 13 | import { applyToDoc } from './utils'; 14 | import { Query } from './query'; 15 | 16 | export class FirestoreTransaction { 17 | constructor(private firestore: Firestore, private transaction: Transaction) {} 18 | 19 | async findById>( 20 | repo: BaseRepository, 21 | ids: DocumentIds 22 | ): Promise { 23 | const doc = this.firestore.doc(repo.getDocumentPath(ids)); 24 | const data = await this.transaction.get(doc); 25 | if (data.exists) { 26 | return repo.fromFirestoreToObject(data as DocumentSnapshot); 27 | } 28 | return null; 29 | } 30 | 31 | /** @deprecated Use findById() instead */ 32 | async find>( 33 | repo: BaseRepository, 34 | ids: DocumentIds 35 | ): Promise { 36 | return this.findById(repo, ids); 37 | } 38 | 39 | async getById>( 40 | repo: BaseRepository, 41 | ids: DocumentIds 42 | ): Promise { 43 | const doc = await this.findById(repo, ids); 44 | if (!doc) { 45 | throw new FirestoreStorageError(repo.getPath().path(), ids); 46 | } 47 | return doc; 48 | } 49 | 50 | /** @deprecated Use getById() instead */ 51 | async get>( 52 | repo: BaseRepository, 53 | ids: DocumentIds 54 | ): Promise { 55 | return this.getById(repo, ids); 56 | } 57 | 58 | async query>( 59 | repo: BaseRepository, 60 | cb: (qb: Query) => Query, 61 | ids: CollectionIds 62 | ): Promise { 63 | const path = repo.getCollectionPath(ids); 64 | const query = cb(new Query(this.firestore.collection(path))).getQuery(); 65 | const documents = await this.transaction.get(query); 66 | return documents.docs.map((doc) => { 67 | return repo.fromFirestoreToObject(doc as DocumentSnapshot); 68 | }); 69 | } 70 | 71 | create>( 72 | repo: BaseRepository, 73 | data: T | ModelDataOnly, 74 | ids: CollectionIds 75 | ): FirestoreTransaction { 76 | return this.applyToDoc(repo, data, ids, (id, data, doc) => { 77 | this.transaction.create(doc, data); 78 | }); 79 | } 80 | 81 | save>( 82 | repo: BaseRepository, 83 | data: T | ModelDataOnly | PatchUpdate>, 84 | ids: CollectionIds 85 | ): FirestoreTransaction { 86 | return this.applyToDoc(repo, data, ids, (id, data, doc) => { 87 | this.transaction.set(doc, data, { merge: true }); 88 | }); 89 | } 90 | 91 | write>( 92 | repo: BaseRepository, 93 | data: T | ModelDataOnly, 94 | ids: CollectionIds 95 | ): FirestoreTransaction { 96 | return this.applyToDoc(repo, data, ids, (id, data, doc) => { 97 | this.transaction.set(doc, data, { merge: false }); 98 | }); 99 | } 100 | 101 | update>( 102 | repo: BaseRepository, 103 | data: T | ModelDataOnly, 104 | ids: CollectionIds 105 | ): FirestoreTransaction { 106 | return this.applyToDoc(repo, data, ids, (id, data, doc) => { 107 | this.transaction.update(doc, data); 108 | }); 109 | } 110 | 111 | delete>( 112 | repo: BaseRepository, 113 | ids: DocumentIds 114 | ): FirestoreTransaction { 115 | const ref = this.firestore.doc(repo.getDocumentPath(ids)); 116 | this.transaction.delete(ref); 117 | return this; 118 | } 119 | 120 | private applyToDoc>( 121 | repo: BaseRepository, 122 | data: T | ModelDataOnly | PatchUpdate>, 123 | ids: CollectionIds, 124 | cb: (id: string, data: ModelDataOnly, doc: DocumentReference) => void 125 | ) { 126 | return applyToDoc(this.firestore, repo, data, ids, (id, data, doc) => { 127 | cb(id, data, doc); 128 | return this; 129 | }); 130 | } 131 | } 132 | 133 | export function runFirestoreTransaction( 134 | firestore: Firestore, 135 | cb: (trx: FirestoreTransaction) => Promise 136 | ): Promise { 137 | return firestore.runTransaction(async (t) => { 138 | const trx = new FirestoreTransaction(firestore, t); 139 | return cb(trx); 140 | }); 141 | } 142 | -------------------------------------------------------------------------------- /packages/firestore/src/lib/storage/utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BaseModel, 3 | BaseRepository, 4 | CollectionIds, 5 | CollectionPath, 6 | ModelDataOnly, 7 | ModelDataWithId, 8 | PatchUpdate, 9 | } from 'firestore-storage-core'; 10 | import { DocumentReference, Firestore } from '@google-cloud/firestore'; 11 | 12 | export function applyToDoc, R = void>( 13 | firestore: Firestore, 14 | repo: BaseRepository, 15 | data: T | ModelDataOnly | PatchUpdate>, 16 | ids: CollectionIds, 17 | cb: (id: string, data: ModelDataOnly, doc: DocumentReference) => R 18 | ) { 19 | const d = repo.toFirestoreDocument(data); 20 | const path = repo.getPath(); 21 | const docRef = d.id 22 | ? firestore.doc(repo.getDocumentPath(path.toDocIds(ids, d.id))) 23 | : firestore.collection(path.collection(ids)).doc(); 24 | 25 | return cb(docRef.id, d.data, docRef); 26 | } 27 | -------------------------------------------------------------------------------- /packages/firestore/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig-base.json", 3 | "compilerOptions": { 4 | "outDir": "./dist", 5 | "rootDir": "./src", 6 | "baseUrl": "./", 7 | "types": [ 8 | "node", 9 | "mocha" 10 | ], 11 | "strict": false, 12 | "paths": { 13 | "firestore-storage-core": ["../core"] 14 | } 15 | }, 16 | "include": ["src/**/*"], 17 | "exclude": ["node_modules"], 18 | "references": [ 19 | {"path": "../core" } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /packages/function-utils/README.md: -------------------------------------------------------------------------------- 1 | # Firestore Function Utils 2 | [![npm version](https://badge.fury.io/js/firestore-function-utils.svg)](https://badge.fury.io/js/firestore-function-utils) 3 | 4 | 5 | ## Overview 6 | This zero dependency package includes some utility functions and types for writing 7 | Google Cloud Function triggers for Firestore. 8 | 9 | ## Example 10 | 11 | ```typescript 12 | export function onUserChange(change: FirestoreChange, event: FirestoreEvent) { 13 | const before = parseFirestoreChangeValue('userId', change, event); 14 | const after = parseFirestoreChangeValue('userId', change, event); 15 | console.log(before); 16 | /* 17 | { 18 | id: "0vdxYqEisf5vwJLhyLjA" 19 | createdAt: Date('2019-04-29T16:35:33.195Z'), 20 | updatedAt: Date('2019-04-29T16:35:33.195Z'), 21 | data: { 22 | username: 'johndoe', 23 | gender: 'male', 24 | newsletter: true 25 | } 26 | } 27 | */ 28 | 29 | } 30 | ``` 31 | -------------------------------------------------------------------------------- /packages/function-utils/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "firestore-function-utils", 3 | "version": "2.0.10", 4 | "description": "Utility functions for writing Cloud Functions with Firestore", 5 | "files": [ 6 | "dist/lib", 7 | "README.md", 8 | "package.json" 9 | ], 10 | "main": "dist/lib/index.js", 11 | "repository": { 12 | "type": "git", 13 | "url": "git@github.com:freshfox/firestore-storage.git" 14 | }, 15 | "author": "Dominic Bartl", 16 | "scripts": { 17 | "test": "echo No tests in this package available", 18 | "clean": "rm -rf dist tsconfig.tsbuildinfo", 19 | "build": "tsc" 20 | }, 21 | "license": "MIT", 22 | "devDependencies": { 23 | "@types/mocha": "^9.0.0", 24 | "@types/node": "^14.11.8", 25 | "firebase-admin": "^11.8.0", 26 | "firebase-functions": "^4.4.0", 27 | "firebase-functions-test": "^3.1.0", 28 | "firestore-storage-core": "^7.0.3", 29 | "mocha": "^9.1.3", 30 | "should": "^13.2.3", 31 | "ts-node": "^10.4.0", 32 | "typescript": "^4.0.3" 33 | }, 34 | "peerDependencies": { 35 | "firebase-admin": "^11.8.0", 36 | "firebase-functions": "^4.4.0", 37 | "firestore-storage-core": "^6.0.4" 38 | }, 39 | "gitHead": "0b06debcfa978dcfd12f74599cded3802179e34b" 40 | } 41 | -------------------------------------------------------------------------------- /packages/function-utils/src/lib/index.ts: -------------------------------------------------------------------------------- 1 | export * from './utils'; 2 | -------------------------------------------------------------------------------- /packages/function-utils/src/lib/integration.test.ts: -------------------------------------------------------------------------------- 1 | import { onDocumentCreated, onDocumentWritten } from 'firebase-functions/v2/firestore'; 2 | import { parseFirestoreChange, parseFirestoreCreate } from './utils'; 3 | import { CollectionPath } from 'firestore-storage-core'; 4 | import 'should'; 5 | import functionTest from 'firebase-functions-test'; 6 | 7 | describe('Functions', function () { 8 | const Users = new CollectionPath('users', 'userId'); 9 | const app = functionTest({ 10 | projectId: 'demo-functions', 11 | }); 12 | const path = Users.doc({ userId: 'u1' }); 13 | 14 | // Make snapshot for state of database beforehand 15 | const beforeSnap = app.firestore.makeDocumentSnapshot({ foo: 'bar' }, path); 16 | // Make snapshot for state of database after the change 17 | const afterSnap = app.firestore.makeDocumentSnapshot({ foo: 'faz' }, path); 18 | 19 | it('should check return type of create function', async () => { 20 | const fn = onDocumentCreated(Users.path(), (event) => { 21 | return parseFirestoreCreate(event, Users); 22 | }); 23 | 24 | // Call wrapped function with the Change object 25 | const wrapped = app.wrap(fn); 26 | const result = wrapped(beforeSnap); 27 | result.should.properties('ids', 'data'); 28 | }); 29 | 30 | it('should check return type of update function', async () => { 31 | const fn = onDocumentWritten(Users.path(), (event) => { 32 | return parseFirestoreChange(event, Users); 33 | }); 34 | const change = app.makeChange(beforeSnap, afterSnap); 35 | // Call wrapped function with the Change object 36 | const wrapped = app.wrap(fn); 37 | const result = wrapped(change); 38 | result.should.properties('ids', 'before', 'after'); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /packages/function-utils/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import * as admin from 'firebase-admin'; 2 | import { Change, FirestoreEvent } from 'firebase-functions/v2/firestore'; 3 | import QueryDocumentSnapshot = admin.firestore.QueryDocumentSnapshot; 4 | import DocumentSnapshot = admin.firestore.DocumentSnapshot; 5 | import { 6 | BaseModel, 7 | CollectionPath, 8 | IDocumentTransformer, 9 | DEFAULT_DOCUMENT_TRANSFORMER, 10 | DocumentIds, 11 | } from 'firestore-storage-core'; 12 | 13 | type ParsedChange = { 14 | before: T | null; 15 | after: T | null; 16 | ids: TIds; 17 | }; 18 | 19 | type ParsedSnapshot = { 20 | data: T | null; 21 | ids: TIds; 22 | }; 23 | 24 | export function parseFirestoreChange>( 25 | event: FirestoreEvent | undefined>, 26 | path: TCollPath, 27 | transformer?: IDocumentTransformer 28 | ): ParsedChange> { 29 | const ids = path.parse(event.document); 30 | transformer = transformer || (DEFAULT_DOCUMENT_TRANSFORMER as IDocumentTransformer); 31 | return { 32 | ids: ids, 33 | before: transform(event.data?.before, transformer) as T, 34 | after: transform(event.data?.after, transformer) as T, 35 | }; 36 | } 37 | 38 | export function parseFirestoreCreate>( 39 | event: FirestoreEvent, 40 | path: TCollPath, 41 | transformer?: IDocumentTransformer 42 | ): ParsedSnapshot> { 43 | const ids = path.parse(event.document); 44 | transformer = transformer || (DEFAULT_DOCUMENT_TRANSFORMER as IDocumentTransformer); 45 | return { 46 | ids: ids, 47 | data: transform(event.data, transformer) as T, 48 | }; 49 | } 50 | 51 | function transform( 52 | doc: DocumentSnapshot | undefined, 53 | transformer: IDocumentTransformer 54 | ): T | null { 55 | const data = doc?.data(); 56 | if (doc && data) { 57 | return transformer.fromFirestoreToObject(data as any, { 58 | id: doc.id, 59 | rawPath: doc.ref.path, 60 | }); 61 | } 62 | return null; 63 | } 64 | -------------------------------------------------------------------------------- /packages/function-utils/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig-base.json", 3 | "compilerOptions": { 4 | "outDir": "./dist", 5 | "rootDir": "./src", 6 | "baseUrl": "./", 7 | "types": [ 8 | "node", 9 | "mocha", 10 | ], 11 | "paths": { 12 | "firestore-storage-core": ["../core"] 13 | } 14 | }, 15 | "include": ["src/**/*"], 16 | "exclude": ["node_modules"], 17 | "references": [ 18 | {"path": "../core" } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /packages/indexes/.mocharc.yaml: -------------------------------------------------------------------------------- 1 | recursive: true 2 | exit: true 3 | -------------------------------------------------------------------------------- /packages/indexes/README.md: -------------------------------------------------------------------------------- 1 | # firestore-indexes 2 | 3 | `firestore-indexes` is a tiny helper class which helps you write indexes for Firestore using Typescript 4 | and lets you generate the corresponding `firestore-indexes.json` file during your build process 5 | 6 | ## Install 7 | npm install firestore-indexes 8 | 9 | ## Usage 10 | 11 | ```typescript 12 | import {IndexManager, QueryScope} from 'firestore-indexes'; 13 | 14 | interface User { 15 | name: string; 16 | registeredAt: Date; 17 | address: { 18 | street: string; 19 | zip: number 20 | } 21 | } 22 | 23 | export const indexManager = new IndexManager() 24 | .addIndex('users', QueryScope.Collection) 25 | /**/.field('name') 26 | /**/.field(u => u.address.street) 27 | /**/.add() 28 | .addIndex('users', QueryScope.Collection) 29 | /**/.field('address.city') 30 | /**/.field('address.zip') 31 | /**/.add() 32 | ``` 33 | -------------------------------------------------------------------------------- /packages/indexes/bin/firestore-indexes.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | const { IndexManager } = require('../dist/lib'); 5 | 6 | const args = [...process.argv]; 7 | args.splice(0, 2); 8 | const command = args.splice(0, 1)[0]; 9 | 10 | const commands = { 11 | generate: (input, output) => { 12 | if (!input || !output) { 13 | console.error('Missing parameters'); 14 | console.error('Usage: $ firestore-indexes generate:index '); 15 | process.exit(1); 16 | } 17 | 18 | console.log('Loading ' + input); 19 | const { indexManager } = require(path.join(process.cwd(), input)); 20 | if (!(indexManager instanceof IndexManager)) { 21 | console.error(input + ' must export a variable called `indexManager` which is an IndexManager instance'); 22 | process.exit(1); 23 | } 24 | console.log('Writing to ' + output); 25 | const json = indexManager.toJSON(2); 26 | fs.writeFileSync(path.join(process.cwd(), output), json, 'utf8'); 27 | }, 28 | }; 29 | 30 | const func = commands[command]; 31 | 32 | if (func) { 33 | func(...args); 34 | } else { 35 | console.error( 36 | `Unrecognised command ${command}. Available commands:\n${Object.keys(commands) 37 | .map((c) => ` - ${c}`) 38 | .join('\n')}` 39 | ); 40 | process.exit(1); 41 | } 42 | -------------------------------------------------------------------------------- /packages/indexes/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "firestore-indexes", 3 | "version": "3.0.2", 4 | "description": "Manage and generate Firestore indexes using typescript", 5 | "files": [ 6 | "bin", 7 | "dist/lib", 8 | "src/lib", 9 | "README.md", 10 | "package.json" 11 | ], 12 | "main": "dist/lib/index.js", 13 | "bin": { 14 | "firestore-indexes": "bin/firestore-indexes.js" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git@github.com:freshfox/firestore-storage.git" 19 | }, 20 | "author": "Dominic Bartl", 21 | "scripts": { 22 | "test": "NODE_ENV=test mocha dist/test", 23 | "clean": "rm -rf dist tsconfig.tsbuildinfo", 24 | "build": "pnpm clean && tsc --build", 25 | "fss": "fss" 26 | }, 27 | "license": "MIT", 28 | "devDependencies": { 29 | "@types/mocha": "^9.0.0", 30 | "@types/node": "14", 31 | "mocha": "^9.1.3", 32 | "should": "^13.2.3", 33 | "typescript": "^4.5.5" 34 | }, 35 | "dependencies": { 36 | "ts-object-path": "^0.1.2" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /packages/indexes/src/lib/index.ts: -------------------------------------------------------------------------------- 1 | export * from './index_manger'; 2 | -------------------------------------------------------------------------------- /packages/indexes/src/lib/index_manger.ts: -------------------------------------------------------------------------------- 1 | import { extractPathParam, KeyOf } from './path'; 2 | 3 | export class IndexManager { 4 | indexes: IIndexEntry[] = []; 5 | fieldOverrides: IFieldOverride[] = []; 6 | 7 | addIndex(collectionGroup: string, queryScope: QueryScope) { 8 | return new IndexBuilder(this, collectionGroup, queryScope); 9 | } 10 | 11 | addOverride(collectionGroup: string, fieldPath: KeyOf) { 12 | return new FieldOverrideBuilder(this, collectionGroup, fieldPath); 13 | } 14 | 15 | toObject(): IFirestoreIndex { 16 | return { 17 | indexes: this.indexes, 18 | fieldOverrides: this.fieldOverrides, 19 | }; 20 | } 21 | 22 | toJSON(space?: string | number) { 23 | return JSON.stringify(this.toObject(), null, space); 24 | } 25 | } 26 | 27 | class IndexBuilder { 28 | readonly entry: IIndexEntry; 29 | 30 | constructor(private parent: IndexManager, collectionGroup: string, queryScope: QueryScope) { 31 | this.entry = { 32 | collectionGroup, 33 | queryScope, 34 | fields: [], 35 | }; 36 | } 37 | 38 | field(fieldPath: KeyOf, order?: IndexFieldOrder) { 39 | this.entry.fields.push({ 40 | fieldPath: extractPathParam(fieldPath), 41 | order: order || IndexFieldOrder.Asc, 42 | }); 43 | return this; 44 | } 45 | 46 | arrayField(fieldPath: KeyOf, config: FieldArrayConfig) { 47 | this.entry.fields.push({ 48 | fieldPath: extractPathParam(fieldPath), 49 | arrayConfig: config, 50 | }); 51 | return this; 52 | } 53 | 54 | add() { 55 | if (this.entry.fields.length < 2) { 56 | throw new Error(`Not enough fields provided to create an index for ${this.entry.collectionGroup}`); 57 | } 58 | this.parent.indexes.push(this.entry); 59 | return this.parent; 60 | } 61 | } 62 | 63 | class FieldOverrideBuilder { 64 | private readonly entry: IFieldOverride; 65 | 66 | constructor(private parent: IndexManager, collectionGroup: string, fieldPath: KeyOf) { 67 | this.entry = { 68 | collectionGroup: collectionGroup, 69 | fieldPath: extractPathParam(fieldPath), 70 | indexes: [], 71 | }; 72 | } 73 | 74 | order(queryScope: QueryScope, order: IndexFieldOrder) { 75 | this.entry.indexes.push({ queryScope, order }); 76 | return this; 77 | } 78 | 79 | array(queryScope: QueryScope, arrayConfig: FieldArrayConfig) { 80 | this.entry.indexes.push({ queryScope, arrayConfig }); 81 | return this; 82 | } 83 | 84 | add() { 85 | this.parent.fieldOverrides.push(this.entry); 86 | return this.parent; 87 | } 88 | } 89 | 90 | export interface IFirestoreIndex { 91 | indexes: IIndexEntry[]; 92 | fieldOverrides: IFieldOverride[]; 93 | } 94 | 95 | export interface IIndexEntry { 96 | collectionGroup: string; 97 | queryScope: QueryScope; 98 | fields: IIndexField[]; 99 | } 100 | 101 | export type IIndexField = { 102 | fieldPath: string; 103 | order?: IndexFieldOrder; 104 | } & { 105 | fieldPath: string; 106 | arrayConfig?: FieldArrayConfig; 107 | }; 108 | 109 | export interface IFieldOverride { 110 | collectionGroup: string; 111 | fieldPath: string; 112 | indexes: IFieldOverrideIndex[]; 113 | } 114 | 115 | export type IFieldOverrideIndex = 116 | | { 117 | queryScope: QueryScope; 118 | order?: IndexFieldOrder; 119 | } 120 | | { 121 | queryScope: QueryScope; 122 | arrayConfig?: FieldArrayConfig; 123 | }; 124 | 125 | export enum IndexFieldOrder { 126 | Asc = 'ASCENDING', 127 | Desc = 'DESCENDING', 128 | } 129 | 130 | export enum FieldArrayConfig { 131 | Contains = 'CONTAINS', 132 | } 133 | 134 | export enum QueryScope { 135 | Collection = 'COLLECTION', 136 | CollectionGroup = 'COLLECTION_GROUP', 137 | } 138 | -------------------------------------------------------------------------------- /packages/indexes/src/lib/path.ts: -------------------------------------------------------------------------------- 1 | import { getPath } from 'ts-object-path'; 2 | export type KeyOf = string | keyof T | ((t: T) => unknown); 3 | 4 | export function extractPathParam(param: KeyOf): string { 5 | if (typeof param === 'string') return param; 6 | return getPath unknown>(param).join('.'); 7 | } 8 | -------------------------------------------------------------------------------- /packages/indexes/src/test/index_manager_example.ts: -------------------------------------------------------------------------------- 1 | import { IndexManager, QueryScope } from '../lib'; 2 | 3 | interface User { 4 | name: string; 5 | registeredAt: Date; 6 | address: { 7 | street: string; 8 | zip: number; 9 | }; 10 | } 11 | 12 | export const indexManager = new IndexManager() 13 | .addIndex('users', QueryScope.Collection) 14 | /**/ .field('name') 15 | /**/ .field((u) => u.address.street) 16 | /**/ .add() 17 | .addIndex('users', QueryScope.Collection) 18 | /**/ .field('address.city') 19 | /**/ .field('address.zip') 20 | /**/ .add(); 21 | -------------------------------------------------------------------------------- /packages/indexes/src/test/index_manager_test.ts: -------------------------------------------------------------------------------- 1 | import 'should'; 2 | import { IndexManager, QueryScope } from '../lib'; 3 | 4 | describe('IndexManager', function () { 5 | interface User { 6 | name: string; 7 | registeredAt: Date; 8 | address: { 9 | street: string; 10 | zip: number; 11 | }; 12 | } 13 | 14 | it('should create an index', async () => { 15 | const indexJson = new IndexManager() 16 | .addIndex('users', QueryScope.Collection) 17 | /**/ .field('name') 18 | /**/ .field((o) => o.address.street) 19 | /**/ .add() 20 | .addIndex('users', QueryScope.Collection) 21 | /**/ .field('address.city') 22 | /**/ .field('address.zip') 23 | /**/ .add() 24 | .toJSON(); 25 | indexJson.should.eql( 26 | '{"indexes":[{"collectionGroup":"users","queryScope":"COLLECTION","fields":[{"fieldPath":"name","order":"ASCENDING"},{"fieldPath":"address.street","order":"ASCENDING"}]},{"collectionGroup":"users","queryScope":"COLLECTION","fields":[{"fieldPath":"address.city","order":"ASCENDING"},{"fieldPath":"address.zip","order":"ASCENDING"}]}],"fieldOverrides":[]}' 27 | ); 28 | }); 29 | 30 | it('should create an index with a field override', async () => { 31 | const indexJson = new IndexManager().addOverride('renderings', 'jobId').add().toJSON(); 32 | indexJson.should.eql( 33 | '{"indexes":[],"fieldOverrides":[{"collectionGroup":"renderings","fieldPath":"jobId","indexes":[]}]}' 34 | ); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /packages/indexes/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig-base.json", 3 | "compilerOptions": { 4 | "outDir": "./dist", 5 | "rootDir": "./src", 6 | "baseUrl": "./", 7 | "strict": true, 8 | "lib": [ 9 | "es6", "dom" 10 | ], 11 | "types": ["node", "mocha"] 12 | }, 13 | "include": ["src/**/*"], 14 | "exclude": ["node_modules"] 15 | } 16 | -------------------------------------------------------------------------------- /packages/tsconfig-base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["es2021"], 4 | "module": "commonjs", 5 | "target": "es2021", 6 | "skipLibCheck": true, 7 | "declaration": true, 8 | "declarationMap": true, 9 | "sourceMap": true, 10 | "experimentalDecorators": true, 11 | "emitDecoratorMetadata": true, 12 | "composite": true, 13 | "esModuleInterop": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "moduleResolution": "node", 16 | "outDir": "build/node", 17 | "rootDir": "./src", 18 | "strict": true, 19 | "inlineSources": true, 20 | "removeComments": false, 21 | }, 22 | "exclude": [ 23 | "node_modules", 24 | "dist" 25 | ], 26 | "references": [ 27 | {"path": "./core" }, 28 | {"path": "./firestore" } 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'packages/*' 3 | --------------------------------------------------------------------------------