├── test ├── emulator │ ├── firestore.indexes.json │ ├── firestore.rules │ ├── firebase.json │ └── .gitignore ├── helpers.ts ├── scripts │ └── test-with-emulator.sh └── client.test.ts ├── tsconfig.cjs.json ├── tsconfig.esm.json ├── .env.local.example ├── vitest.config.ts ├── src ├── utils │ ├── config.ts │ ├── auth.ts │ ├── converter.ts │ └── path.ts ├── index.ts ├── types.ts └── client.ts ├── tsconfig.json ├── .releaserc ├── .github └── workflows │ └── release.yml ├── .gitignore ├── package.json ├── CHANGELOG.md ├── README.ja.md └── README.md /test/emulator/firestore.indexes.json: -------------------------------------------------------------------------------- 1 | { 2 | "indexes": [], 3 | "fieldOverrides": [] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "outDir": "./dist/cjs" 6 | } 7 | } -------------------------------------------------------------------------------- /tsconfig.esm.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "es2020", 5 | "outDir": "./dist/esm" 6 | } 7 | } -------------------------------------------------------------------------------- /.env.local.example: -------------------------------------------------------------------------------- 1 | FIREBASE_PROJECT_ID=demo-test-project 2 | FIRESTORE_EMULATOR=true 3 | FIRESTORE_EMULATOR_HOST=127.0.0.1 4 | FIRESTORE_EMULATOR_PORT=8089 5 | DEBUG_TESTS=false -------------------------------------------------------------------------------- /test/emulator/firestore.rules: -------------------------------------------------------------------------------- 1 | service cloud.firestore { 2 | match /databases/{database}/documents { 3 | match /{document=**} { 4 | allow read, write: if true; 5 | } 6 | } 7 | } -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | import dotenv from "dotenv"; 3 | 4 | // .env ファイルを読み込む 5 | dotenv.config(); 6 | 7 | export default defineConfig({ 8 | test: { 9 | environment: "node", 10 | globals: true, 11 | testTimeout: 30000, 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /src/utils/config.ts: -------------------------------------------------------------------------------- 1 | import { FirestoreConfig } from "../types"; 2 | 3 | /** 4 | * 秘密鍵の文字列内にある改行コードのエスケープシーケンスを実際の改行に変換する 5 | * @param privateKey 変換する秘密鍵文字列 6 | * @returns 変換後の秘密鍵文字列 7 | */ 8 | export function formatPrivateKey(privateKey: string): string { 9 | if (privateKey.includes("\\n")) { 10 | return privateKey.replace(/\\n/g, "\n"); 11 | } 12 | return privateKey; 13 | } 14 | 15 | -------------------------------------------------------------------------------- /test/emulator/firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "emulators": { 3 | "hub": { 4 | "port": 4089 5 | }, 6 | "firestore": { 7 | "port": 8089 8 | }, 9 | "auth": { 10 | "port": 9089 11 | }, 12 | "ui": { 13 | "enabled": true 14 | } 15 | }, 16 | "firestore": { 17 | "rules": "firestore.rules", 18 | "indexes": "firestore.indexes.json" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "CommonJS", 5 | "declaration": true, 6 | "outDir": "./dist", 7 | "strict": true, 8 | "esModuleInterop": true, 9 | "skipLibCheck": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "moduleResolution": "node", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true 14 | }, 15 | "include": ["src/**/*"], 16 | "exclude": ["node_modules", "dist", "**/*.test.ts"] 17 | } 18 | -------------------------------------------------------------------------------- /.releaserc: -------------------------------------------------------------------------------- 1 | { 2 | "branches": [ 3 | "main" 4 | ], 5 | "plugins": [ 6 | "@semantic-release/commit-analyzer", 7 | "@semantic-release/release-notes-generator", 8 | "@semantic-release/changelog", 9 | "@semantic-release/npm", 10 | "@semantic-release/github", 11 | [ 12 | "@semantic-release/git", 13 | { 14 | "assets": [ 15 | "package.json", 16 | "package-lock.json", 17 | "CHANGELOG.md" 18 | ], 19 | "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" 20 | } 21 | ] 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // 型定義のエクスポート 2 | export * from "./types"; 3 | 4 | // クライアントのエクスポート 5 | import { 6 | FirestoreClient, 7 | createFirestoreClient, 8 | CollectionReference, 9 | DocumentReference, 10 | CollectionGroup, 11 | Query, 12 | QuerySnapshot, 13 | DocumentSnapshot, 14 | WriteResult, 15 | } from "./client"; 16 | 17 | // ユーティリティ関数のエクスポート 18 | export { getFirestoreToken } from "./utils/auth"; 19 | export { 20 | convertToFirestoreValue, 21 | convertFromFirestoreValue, 22 | convertToFirestoreDocument, 23 | convertFromFirestoreDocument, 24 | } from "./utils/converter"; 25 | export { getFirestoreBasePath, getDocumentId } from "./utils/path"; 26 | export { formatPrivateKey } from "./utils/config"; 27 | 28 | // クライアント関連のエクスポート 29 | export { 30 | FirestoreClient, 31 | createFirestoreClient, 32 | CollectionReference, 33 | DocumentReference, 34 | CollectionGroup, 35 | Query, 36 | QuerySnapshot, 37 | DocumentSnapshot, 38 | WriteResult, 39 | }; 40 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | # GitHub Actionsの権限設定 9 | permissions: 10 | contents: write 11 | issues: write 12 | pull-requests: write 13 | 14 | jobs: 15 | release: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v3 20 | with: 21 | fetch-depth: 0 22 | 23 | - name: Setup Node.js 24 | uses: actions/setup-node@v3 25 | with: 26 | node-version: "20.x" 27 | registry-url: "https://registry.npmjs.org" 28 | 29 | - name: Gitの設定 30 | run: | 31 | git config --local user.email "github-actions[bot]@users.noreply.github.com" 32 | git config --local user.name "github-actions[bot]" 33 | 34 | - name: インストール 35 | run: npm ci 36 | 37 | - name: テスト実行 38 | env: 39 | FIREBASE_PROJECT_ID: ${{ secrets.FIREBASE_PROJECT_ID }} 40 | FIREBASE_CLIENT_EMAIL: ${{ secrets.FIREBASE_CLIENT_EMAIL }} 41 | FIREBASE_PRIVATE_KEY: ${{ secrets.FIREBASE_PRIVATE_KEY }} 42 | run: npm test 43 | 44 | - name: リリース実行 45 | env: 46 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 47 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 48 | run: npx semantic-release 49 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Firestoreクライアントの設定インターフェース 3 | */ 4 | export interface FirestoreConfig { 5 | projectId: string; 6 | privateKey: string; 7 | clientEmail: string; 8 | databaseId?: string; 9 | debug?: boolean; 10 | useEmulator?: boolean; 11 | emulatorHost?: string; 12 | emulatorPort?: number; 13 | } 14 | 15 | /** 16 | * Firestoreの値型定義 17 | */ 18 | export type FirestoreFieldValue = 19 | | { stringValue: string } 20 | | { integerValue: number } 21 | | { doubleValue: number } 22 | | { booleanValue: boolean } 23 | | { nullValue: null } 24 | | { timestampValue: string } 25 | | { mapValue: { fields: Record } } 26 | | { arrayValue: { values: FirestoreFieldValue[] } }; 27 | 28 | /** 29 | * Firestoreドキュメント型 30 | */ 31 | export interface FirestoreDocument { 32 | name?: string; 33 | fields: Record; 34 | createTime?: string; 35 | updateTime?: string; 36 | } 37 | 38 | /** 39 | * Firestoreレスポンス型 40 | */ 41 | export interface FirestoreResponse { 42 | name: string; 43 | fields?: Record; 44 | createTime?: string; 45 | updateTime?: string; 46 | } 47 | 48 | /** 49 | * クエリオプション型 50 | */ 51 | export interface QueryOptions { 52 | where?: Array<{ field: string; op: string; value: any }>; 53 | orderBy?: string; 54 | orderDirection?: string; 55 | limit?: number; 56 | offset?: number; 57 | } 58 | -------------------------------------------------------------------------------- /test/emulator/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | firebase-debug.log* 8 | firebase-debug.*.log* 9 | 10 | # Firebase cache 11 | .firebase/ 12 | 13 | # Firebase config 14 | 15 | # Uncomment this if you'd like others to create their own Firebase project. 16 | # For a team working on the same Firebase project(s), it is recommended to leave 17 | # it commented so all members can deploy to the same project(s) in .firebaserc. 18 | # .firebaserc 19 | 20 | # Runtime data 21 | pids 22 | *.pid 23 | *.seed 24 | *.pid.lock 25 | 26 | # Directory for instrumented libs generated by jscoverage/JSCover 27 | lib-cov 28 | 29 | # Coverage directory used by tools like istanbul 30 | coverage 31 | 32 | # nyc test coverage 33 | .nyc_output 34 | 35 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 36 | .grunt 37 | 38 | # Bower dependency directory (https://bower.io/) 39 | bower_components 40 | 41 | # node-waf configuration 42 | .lock-wscript 43 | 44 | # Compiled binary addons (http://nodejs.org/api/addons.html) 45 | build/Release 46 | 47 | # Dependency directories 48 | node_modules/ 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Optional REPL history 57 | .node_repl_history 58 | 59 | # Output of 'npm pack' 60 | *.tgz 61 | 62 | # Yarn Integrity file 63 | .yarn-integrity 64 | 65 | # dotenv environment variables file 66 | .env -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript cache 40 | *.tsbuildinfo 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional stylelint cache 49 | .stylelintcache 50 | 51 | # Microbundle cache 52 | .rpt2_cache/ 53 | .rts2_cache_cjs/ 54 | .rts2_cache_es/ 55 | .rts2_cache_umd/ 56 | 57 | # Optional REPL history 58 | .node_repl_history 59 | 60 | # Output of 'npm pack' 61 | *.tgz 62 | 63 | # Yarn Integrity file 64 | .yarn-integrity 65 | 66 | # dotenv environment variable files 67 | .env 68 | .env.local 69 | .env.development.local 70 | .env.test.local 71 | .env.production.local 72 | .env.local 73 | 74 | # Firebase 75 | .firebase/ 76 | firebase-debug.log 77 | firestore-debug.log 78 | ui-debug.log 79 | .runtimeconfig.json 80 | 81 | # Build directories 82 | dist/ 83 | build/ 84 | out/ 85 | 86 | # Editor directories and files 87 | .idea/ 88 | .vscode/ 89 | *.suo 90 | *.ntvs* 91 | *.njsproj 92 | *.sln 93 | *.sw? 94 | .DS_Store -------------------------------------------------------------------------------- /src/utils/auth.ts: -------------------------------------------------------------------------------- 1 | import * as jose from "jose"; 2 | import { FirestoreConfig } from "../types"; 3 | 4 | /** 5 | * Function to create a JWT (JSON Web Token) 6 | * @param config Firestore configuration 7 | * @returns JWT string 8 | */ 9 | export async function createJWT(config: FirestoreConfig): Promise { 10 | const now = Math.floor(Date.now() / 1000); 11 | const payload = { 12 | iss: config.clientEmail, 13 | sub: config.clientEmail, 14 | aud: "https://oauth2.googleapis.com/token", 15 | iat: now, 16 | exp: now + 3600, // Expires in 1 hour 17 | scope: "https://www.googleapis.com/auth/datastore", 18 | }; 19 | 20 | try { 21 | // Import the private key 22 | const privateKey = await jose.importPKCS8(config.privateKey, "RS256"); 23 | 24 | // Create JWT 25 | const token = await new jose.SignJWT(payload) 26 | .setProtectedHeader({ 27 | alg: "RS256", 28 | typ: "JWT", 29 | }) 30 | .sign(privateKey); 31 | 32 | return token; 33 | } catch (error) { 34 | console.error("Error creating JWT:", error); 35 | throw error; 36 | } 37 | } 38 | 39 | /** 40 | * Function to get Firestore authentication token 41 | * @param config Firestore configuration 42 | * @returns Access token 43 | */ 44 | export async function getFirestoreToken( 45 | config: FirestoreConfig 46 | ): Promise { 47 | // No authentication in emulator mode (returns a dummy token) 48 | if (config.useEmulator) { 49 | return "firebase-emulator-auth-token"; 50 | } 51 | 52 | // Normal authentication process 53 | const response = await fetch("https://oauth2.googleapis.com/token", { 54 | method: "POST", 55 | headers: { 56 | "Content-Type": "application/json", 57 | }, 58 | body: JSON.stringify({ 59 | grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", 60 | assertion: await createJWT(config), 61 | }), 62 | }); 63 | 64 | const data = (await response.json()) as { access_token: string }; 65 | return data.access_token; 66 | } 67 | -------------------------------------------------------------------------------- /test/helpers.ts: -------------------------------------------------------------------------------- 1 | import { FirestoreConfig } from "../src/types"; 2 | import { formatPrivateKey } from "../src/utils/config"; 3 | import dotenv from "dotenv"; 4 | 5 | // Load environment variables from .env file 6 | dotenv.config(); 7 | 8 | /** 9 | * Load settings from environment variables 10 | * @returns Firestore configuration object 11 | */ 12 | export function loadConfig(): FirestoreConfig { 13 | const projectId = process.env.FIREBASE_PROJECT_ID; 14 | const clientEmail = process.env.FIREBASE_CLIENT_EMAIL; 15 | let privateKey = process.env.FIREBASE_PRIVATE_KEY; 16 | 17 | // Load emulator settings from environment variables 18 | const useEmulator = process.env.FIRESTORE_EMULATOR === "true"; 19 | const emulatorHost = process.env.FIRESTORE_EMULATOR_HOST || "localhost"; 20 | const emulatorPort = parseInt(process.env.FIRESTORE_EMULATOR_PORT || "8080"); 21 | 22 | // Project ID is always required, but email and key only required for non-emulator mode 23 | if (!projectId) { 24 | throw new Error( 25 | "FIREBASE_PROJECT_ID environment variable is not set. Please check the .env file." 26 | ); 27 | } 28 | 29 | if (!useEmulator && (!clientEmail || !privateKey)) { 30 | throw new Error( 31 | "FIREBASE_CLIENT_EMAIL and FIREBASE_PRIVATE_KEY are required when not using the emulator. Please check the .env file." 32 | ); 33 | } 34 | 35 | // Only format the private key if it exists 36 | if (privateKey) { 37 | privateKey = formatPrivateKey(privateKey); 38 | } 39 | 40 | // Debug mode settings 41 | const debug = process.env.DEBUG_TESTS === "true"; 42 | 43 | return { 44 | projectId, 45 | clientEmail: clientEmail || "", 46 | privateKey: privateKey || "", 47 | useEmulator, 48 | emulatorHost, 49 | emulatorPort, 50 | debug 51 | }; 52 | } 53 | 54 | /** 55 | * Generate collection name for testing 56 | * @param prefix Collection name prefix 57 | * @returns Unique collection name 58 | */ 59 | export function getTestCollectionName(prefix: string = "test"): string { 60 | const timestamp = Date.now(); 61 | const random = Math.floor(Math.random() * 10000); 62 | return `${prefix}_${timestamp}_${random}`; 63 | } 64 | 65 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "firebase-rest-firestore", 3 | "version": "1.5.0", 4 | "description": "Firebase Firestore REST API client for Edge runtime environments", 5 | "main": "dist/cjs/index.js", 6 | "module": "dist/esm/index.js", 7 | "types": "dist/types/index.d.ts", 8 | "files": [ 9 | "dist", 10 | "README.md" 11 | ], 12 | "scripts": { 13 | "build": "npm run build:esm && npm run build:cjs && npm run build:types", 14 | "build:esm": "tsc -p tsconfig.esm.json", 15 | "build:cjs": "tsc -p tsconfig.cjs.json", 16 | "build:types": "tsc -p tsconfig.json --emitDeclarationOnly --declarationDir dist/types", 17 | "watch": "concurrently \"npm run watch:esm\" \"npm run watch:cjs\" \"npm run watch:types\"", 18 | "watch:esm": "tsc -p tsconfig.esm.json --watch", 19 | "watch:cjs": "tsc -p tsconfig.cjs.json --watch", 20 | "watch:types": "tsc -p tsconfig.json --emitDeclarationOnly --declarationDir dist/types --watch", 21 | "prepublishOnly": "npm run build", 22 | "setup:local:env": "cp .env.local.example .env && echo 'Created .env file from local example.'", 23 | "emulator:start": "cd test/emulator && firebase emulators:start -P demo-test-project", 24 | "emulator:stop": "npx kill-port -y 4089 8089 9089", 25 | "test": "vitest", 26 | "test:emulator": "bash test/scripts/test-with-emulator.sh" 27 | }, 28 | "keywords": [ 29 | "firebase", 30 | "firestore", 31 | "rest", 32 | "api", 33 | "edge", 34 | "cloudflare", 35 | "workers", 36 | "vercel" 37 | ], 38 | "author": "", 39 | "license": "MIT", 40 | "dependencies": { 41 | "jose": "^4.14.4" 42 | }, 43 | "devDependencies": { 44 | "@semantic-release/changelog": "^6.0.3", 45 | "@semantic-release/git": "^10.0.1", 46 | "@types/node": "^18.16.0", 47 | "concurrently": "^8.2.2", 48 | "dotenv": "^16.4.7", 49 | "semantic-release": "^24.2.3", 50 | "typescript": "^5.0.4", 51 | "vitest": "^3.0.9" 52 | }, 53 | "engines": { 54 | "node": ">=20.8.1" 55 | }, 56 | "repository": { 57 | "type": "git", 58 | "url": "git+https://github.com/nabettu/firebase-rest-firestore.git" 59 | }, 60 | "bugs": { 61 | "url": "https://github.com/nabettu/firebase-rest-firestore/issues" 62 | }, 63 | "homepage": "https://github.com/nabettu/firebase-rest-firestore#readme" 64 | } 65 | -------------------------------------------------------------------------------- /test/scripts/test-with-emulator.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Ensure we start from the project root 4 | PROJECT_ROOT=$(pwd) 5 | 6 | # Load environment variables from .env file 7 | if [ -f "$PROJECT_ROOT/.env" ]; then 8 | echo "Loading environment variables from .env file" 9 | export $(grep -v '^#' "$PROJECT_ROOT/.env" | xargs) 10 | else 11 | echo "ERROR: No .env file found. Please create one based on .env.local.example" 12 | echo "Copy .env.local.example to .env and modify as needed" 13 | exit 1 14 | fi 15 | 16 | # Display the configuration being used 17 | echo "Using configuration:" 18 | echo "FIREBASE_PROJECT_ID: $FIREBASE_PROJECT_ID" 19 | echo "FIRESTORE_EMULATOR_HOST: $FIRESTORE_EMULATOR_HOST" 20 | echo "FIRESTORE_EMULATOR_PORT: $FIRESTORE_EMULATOR_PORT" 21 | 22 | # Check if Firebase emulator is already running 23 | HOST="${FIRESTORE_EMULATOR_HOST:-127.0.0.1}" 24 | PORT="${FIRESTORE_EMULATOR_PORT:-8089}" 25 | nc -z $HOST $PORT 26 | EMULATOR_RUNNING=$? 27 | 28 | EMULATOR_PID="" 29 | if [[ $EMULATOR_RUNNING -eq 0 ]]; then 30 | echo "Firebase emulator is already running on $HOST:$PORT. Using existing instance." 31 | else 32 | # Start Firebase emulator in the background 33 | echo "Starting new Firebase emulator instance..." 34 | cd "$PROJECT_ROOT/test/emulator" && firebase emulators:start -P ${FIREBASE_PROJECT_ID} & 35 | EMULATOR_PID=$! 36 | 37 | # Wait for Firebase emulator to be fully initialized 38 | counter=0 39 | max_attempts=30 40 | 41 | echo "Waiting for Firebase emulator to be ready..." 42 | 43 | while [[ $counter -lt $max_attempts ]]; do 44 | nc -z $HOST $PORT 45 | result=$? 46 | if [[ $result -eq 0 ]]; then 47 | echo "Firebase emulator on $HOST:$PORT is up!" 48 | break 49 | fi 50 | echo "Waiting for Firebase emulator on $HOST:$PORT... Attempt $((counter+1))/$max_attempts" 51 | sleep 1 52 | ((counter++)) 53 | done 54 | 55 | if [[ $counter -eq $max_attempts ]]; then 56 | echo "Firebase emulator on $HOST:$PORT did not start within $max_attempts attempts." 57 | kill $EMULATOR_PID 58 | exit 1 59 | fi 60 | fi 61 | 62 | # Run the tests with environment variables 63 | cd "$PROJECT_ROOT" && vitest 64 | 65 | # Cleanup - only kill if we started the emulator 66 | if [[ -n "$EMULATOR_PID" ]]; then 67 | echo "Shutting down emulator instance we started (PID: $EMULATOR_PID)" 68 | kill $EMULATOR_PID 69 | fi -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # [1.5.0](https://github.com/nabettu/firebase-rest-firestore/compare/v1.4.0...v1.5.0) (2025-07-04) 2 | 3 | 4 | ### Features 5 | 6 | * trigger npm publish for v1.4.0 changes ([391fa02](https://github.com/nabettu/firebase-rest-firestore/commit/391fa02343ebd912bf821f680aa17098aa9a2abb)) 7 | 8 | # [1.4.0](https://github.com/nabettu/firebase-rest-firestore/compare/v1.3.0...v1.4.0) (2025-07-04) 9 | 10 | 11 | ### Features 12 | 13 | * 不要な関数とインポートを削除し、FirestoreのURL生成を修正 ([d70d089](https://github.com/nabettu/firebase-rest-firestore/commit/d70d089170c106b88eb1cda17c2dcb2c62a8f4c2)) 14 | 15 | # [1.3.0](https://github.com/nabettu/firebase-rest-firestore/compare/v1.2.0...v1.3.0) (2025-07-04) 16 | 17 | 18 | ### Bug Fixes 19 | 20 | * doc method has incorrect validation ([ad0a6fe](https://github.com/nabettu/firebase-rest-firestore/commit/ad0a6fe5fea416bde431dd75aa887c6cd6f823b6)) 21 | 22 | 23 | ### Features 24 | 25 | * add debug flag to control log output visibility ([ca968e2](https://github.com/nabettu/firebase-rest-firestore/commit/ca968e250e82ce12d990946f6a7e9c6dc6cea8d7)) 26 | * add emulator support ([de4cee7](https://github.com/nabettu/firebase-rest-firestore/commit/de4cee7173c36c4557754acbd4fceb42488a8673)) 27 | * add esm support ([2baf7ad](https://github.com/nabettu/firebase-rest-firestore/commit/2baf7ad45335e7828eb1d76b31d836aa1704db91)) 28 | * update package-lock.json dependencies ([e983e84](https://github.com/nabettu/firebase-rest-firestore/commit/e983e8466959619f1871155746eacdc4037582e0)) 29 | 30 | # [1.2.0](https://github.com/nabettu/firebase-rest-firestore/compare/v1.1.2...v1.2.0) (2025-03-25) 31 | 32 | 33 | ### Features 34 | 35 | * ネストしたフィールドの更新機能を追加し、関連するテストを実装 ([aeb4407](https://github.com/nabettu/firebase-rest-firestore/commit/aeb4407913fce8e2029329eda694bc6745156ddb)) 36 | 37 | ## [1.1.2](https://github.com/nabettu/firebase-rest-firestore/compare/v1.1.1...v1.1.2) (2025-03-25) 38 | 39 | 40 | ### Bug Fixes 41 | 42 | * 既存のドキュメントを取得してからマージする機能を追加し、テストを修正 ([d68a11c](https://github.com/nabettu/firebase-rest-firestore/commit/d68a11c9a515c990124164431cb26ab3d369bb2d)) 43 | 44 | ## [1.1.1](https://github.com/nabettu/firebase-rest-firestore/compare/v1.1.0...v1.1.1) (2025-03-25) 45 | 46 | 47 | ### Bug Fixes 48 | 49 | * FirestoreクライアントにデータベースIDのサポートを追加し、関連するURL生成を修正 ([10b4aed](https://github.com/nabettu/firebase-rest-firestore/commit/10b4aedf451f7beff2e3341c583accc1714f9e3c)) 50 | 51 | # [1.1.0](https://github.com/nabettu/firebase-rest-firestore/compare/v1.0.0...v1.1.0) (2025-03-21) 52 | 53 | 54 | ### Features 55 | 56 | * ネストしたコレクションの操作とコレクショングループクエリのテストを追加 ([ddd8327](https://github.com/nabettu/firebase-rest-firestore/commit/ddd8327364fe119d73419742ab2a9c34317bbcfc)) 57 | 58 | # 1.0.0 (2025-03-21) 59 | 60 | 61 | ### Bug Fixes 62 | 63 | * release.ymlからテスト実行ステップを削除し、リリースプロセスを簡素化。 ([0361080](https://github.com/nabettu/firebase-rest-firestore/commit/0361080b401d6276e8bf4ba80cb3d88c6e146282)) 64 | * release.ymlにFirebase環境変数を追加し、テスト実行時に必要な設定を明確化。 ([c82a5a3](https://github.com/nabettu/firebase-rest-firestore/commit/c82a5a3fea96cf612531bbabff96f1d8325f55b5)) 65 | * テストタイムアウトを30000ミリ秒に設定し、テスト実行時の安定性を向上。 ([c32fd41](https://github.com/nabettu/firebase-rest-firestore/commit/c32fd416124a08d26fb7bd315423cf2364df63dc)) 66 | * バージョンを0.3.1に更新し、依存関係に@semantic-release/changelog、@semantic-release/git、semantic-releaseを追加。package-lock.jsonを更新し、関連するモジュールのバージョンを最新に保つ。 ([12bd322](https://github.com/nabettu/firebase-rest-firestore/commit/12bd32296307acf0b98379ee9a1bcfb658fc78d8)) 67 | # 1.4.0 release notes updated 68 | -------------------------------------------------------------------------------- /src/utils/converter.ts: -------------------------------------------------------------------------------- 1 | import { 2 | FirestoreDocument, 3 | FirestoreFieldValue, 4 | FirestoreResponse, 5 | } from "../types"; 6 | import { getDocumentId } from "./path"; 7 | 8 | /** 9 | * JSの値をFirestore形式に変換する 10 | * @param value 変換する値 11 | * @returns Firestore形式の値 12 | */ 13 | export function convertToFirestoreValue(value: any): FirestoreFieldValue { 14 | if (value instanceof Date) { 15 | return { timestampValue: value.toISOString() }; 16 | } else if (typeof value === "string") { 17 | return { stringValue: value }; 18 | } else if (typeof value === "number") { 19 | return Number.isInteger(value) 20 | ? { integerValue: value } 21 | : { doubleValue: value }; 22 | } else if (typeof value === "boolean") { 23 | return { booleanValue: value }; 24 | } else if (value === null || value === undefined) { 25 | return { nullValue: null }; 26 | } else if (Array.isArray(value)) { 27 | return { 28 | arrayValue: { 29 | values: value.map(item => convertToFirestoreValue(item)), 30 | }, 31 | }; 32 | } else if (typeof value === "object") { 33 | const fields = Object.entries(value).reduce( 34 | (acc, [key, val]) => ({ 35 | ...acc, 36 | [key]: convertToFirestoreValue(val), 37 | }), 38 | {} 39 | ); 40 | return { mapValue: { fields } }; 41 | } 42 | 43 | // デフォルトは文字列化 44 | return { stringValue: String(value) }; 45 | } 46 | 47 | /** 48 | * Firestore形式からJSの値に変換する 49 | * @param firestoreValue Firestore形式の値 50 | * @returns JS形式の値 51 | */ 52 | export function convertFromFirestoreValue( 53 | firestoreValue: FirestoreFieldValue 54 | ): any { 55 | if ("stringValue" in firestoreValue) { 56 | return firestoreValue.stringValue; 57 | } else if ("integerValue" in firestoreValue) { 58 | return Number(firestoreValue.integerValue); 59 | } else if ("doubleValue" in firestoreValue) { 60 | return firestoreValue.doubleValue; 61 | } else if ("booleanValue" in firestoreValue) { 62 | return firestoreValue.booleanValue; 63 | } else if ("nullValue" in firestoreValue) { 64 | return null; 65 | } else if ("timestampValue" in firestoreValue) { 66 | return new Date(firestoreValue.timestampValue); 67 | } else if ("mapValue" in firestoreValue && firestoreValue.mapValue.fields) { 68 | return Object.entries(firestoreValue.mapValue.fields).reduce( 69 | (acc, [key, val]) => ({ 70 | ...acc, 71 | [key]: convertFromFirestoreValue(val), 72 | }), 73 | {} 74 | ); 75 | } else if ( 76 | "arrayValue" in firestoreValue && 77 | firestoreValue.arrayValue.values 78 | ) { 79 | return firestoreValue.arrayValue.values.map(convertFromFirestoreValue); 80 | } 81 | 82 | return null; 83 | } 84 | 85 | /** 86 | * オブジェクトをFirestoreドキュメント形式に変換 87 | * @param data 変換するオブジェクト 88 | * @returns Firestoreドキュメント 89 | */ 90 | export function convertToFirestoreDocument( 91 | data: Record 92 | ): FirestoreDocument { 93 | return { 94 | fields: Object.entries(data).reduce( 95 | (acc, [key, value]) => ({ 96 | ...acc, 97 | [key]: convertToFirestoreValue(value), 98 | }), 99 | {} 100 | ), 101 | }; 102 | } 103 | 104 | /** 105 | * Firestoreドキュメントをオブジェクトに変換 106 | * @param doc Firestoreレスポンス 107 | * @returns 変換されたオブジェクト(idプロパティ付き) 108 | */ 109 | export function convertFromFirestoreDocument( 110 | doc: FirestoreResponse 111 | ): Record & { id: string } { 112 | if (!doc.fields) return { id: getDocumentId(doc.name) }; 113 | 114 | const result = Object.entries(doc.fields).reduce( 115 | (acc, [key, value]) => ({ 116 | ...acc, 117 | [key]: convertFromFirestoreValue(value), 118 | }), 119 | {} 120 | ); 121 | 122 | return { 123 | ...result, 124 | id: getDocumentId(doc.name), 125 | }; 126 | } 127 | -------------------------------------------------------------------------------- /README.ja.md: -------------------------------------------------------------------------------- 1 | # Firebase REST Firestore 2 | 3 | Firebase Firestore REST API クライアント - Cloudflare Workers や Vercel Edge Functions などのエッジランタイム環境向け。 4 | 5 | ## 特徴 6 | 7 | - Firebase Admin SDK が利用できないエッジランタイム環境で動作 8 | - 完全な CRUD 操作のサポート 9 | - TypeScript サポート 10 | - パフォーマンス向上のためのトークンキャッシング 11 | - シンプルで直感的な API 12 | - 環境変数への暗黙的な依存がない明示的な設定 13 | 14 | ## インストール 15 | 16 | ```bash 17 | npm install firebase-rest-firestore 18 | ``` 19 | 20 | ## 使用方法 21 | 22 | ```typescript 23 | import { initializeFirestore } from "firebase-rest-firestore"; 24 | 25 | // SDK互換クライアントを初期化 26 | const db = initializeFirestore({ 27 | projectId: "your-project-id", 28 | privateKey: "your-private-key", 29 | clientEmail: "your-client-email", 30 | }); 31 | 32 | // コレクションリファレンスの取得 33 | const gamesRef = db.collection("games"); 34 | 35 | // ドキュメントの追加 36 | const gameRef = await gamesRef.add({ 37 | name: "New Game", 38 | createdAt: new Date(), 39 | score: 100, 40 | }); 41 | 42 | // ドキュメントの取得 43 | const gameSnapshot = await gameRef.get(); 44 | console.log(gameSnapshot.data()); 45 | 46 | // ドキュメントの更新 47 | await gameRef.update({ 48 | score: 200, 49 | }); 50 | 51 | // ドキュメントの削除 52 | await gameRef.delete(); 53 | 54 | // クエリの実行 55 | const highScoreGames = await gamesRef 56 | .where("score", ">", 150) 57 | .where("createdAt", "<", new Date()) 58 | .get(); 59 | 60 | highScoreGames.forEach(doc => { 61 | console.log(doc.id, "=>", doc.data()); 62 | }); 63 | ``` 64 | 65 | ## API リファレンス 66 | 67 | ### createFirestoreClient(config) 68 | 69 | Firestore クライアントを作成します。 70 | 71 | #### パラメータ 72 | 73 | - `config` (object): クライアント設定 74 | - `projectId` (string): Firebase プロジェクト ID 75 | - `privateKey` (string): サービスアカウントの秘密鍵 76 | - `clientEmail` (string): サービスアカウントのメールアドレス 77 | 78 | #### 戻り値 79 | 80 | 以下のメソッドを持つ Firestore クライアントオブジェクト: 81 | 82 | ### collection(collectionPath).add(data) 83 | 84 | コレクション内に自動生成された ID を持つ新しいドキュメントを作成します。 85 | 86 | #### パラメータ 87 | 88 | - `data` (object): ドキュメントデータ 89 | 90 | #### 戻り値 91 | 92 | 作成されたドキュメントへの参照。 93 | 94 | ### collection(collectionPath).doc(id?).set(data) 95 | 96 | 指定された ID でドキュメントを作成または上書きします。ID が指定されていない場合は自動的に生成されます。 97 | 98 | #### パラメータ 99 | 100 | - `id` (string, オプション): ドキュメントの ID 101 | - `data` (object): ドキュメントデータ 102 | 103 | #### 戻り値 104 | 105 | プロミス(作成または上書き操作の完了時に解決)。 106 | 107 | ### client.get(collection, id) 108 | 109 | ドキュメントを取得します。 110 | 111 | #### パラメータ 112 | 113 | - `collection` (string): ドキュメントが属するコレクション名 114 | - `id` (string): 取得するドキュメントの ID 115 | 116 | #### 戻り値 117 | 118 | ドキュメントデータを含むオブジェクト。ドキュメントが存在しない場合は null。 119 | 120 | ### client.update(collection, id, data) 121 | 122 | 既存のドキュメントを更新します。 123 | 124 | #### パラメータ 125 | 126 | - `collection` (string): ドキュメントが属するコレクション名 127 | - `id` (string): 更新するドキュメントの ID 128 | - `data` (object): 更新するフィールドを含むオブジェクト 129 | 130 | #### 戻り値 131 | 132 | 更新されたドキュメントを表すオブジェクト。 133 | 134 | ### client.delete(collection, id) 135 | 136 | ドキュメントを削除します。 137 | 138 | #### パラメータ 139 | 140 | - `collection` (string): ドキュメントが属するコレクション名 141 | - `id` (string): 削除するドキュメントの ID 142 | 143 | #### 戻り値 144 | 145 | 成功した場合は true。 146 | 147 | ### client.query(collection, filters, options?) 148 | 149 | コレクションに対してクエリを実行します。 150 | 151 | #### パラメータ 152 | 153 | - `collection` (string): クエリするコレクション名 154 | - `filters` (array): 各フィルタは[フィールド, 演算子, 値]の形式の配列 155 | - `options` (object, オプション): 156 | - `orderBy` (array, オプション): 並べ替えの指定(例:[['score', 'desc'], ['createdAt', 'asc']]) 157 | - `limit` (number, オプション): 結果の最大数 158 | - `offset` (number, オプション): スキップする結果の数 159 | - `startAt` (any, オプション): この値から始まるドキュメントを返す 160 | - `startAfter` (any, オプション): この値の後に始まるドキュメントを返す 161 | - `endAt` (any, オプション): この値で終わるドキュメントを返す 162 | - `endBefore` (any, オプション): この値の前に終わるドキュメントを返す 163 | 164 | #### 戻り値 165 | 166 | クエリ条件に一致するドキュメントの配列。 167 | 168 | ## Next.js 環境での設定 169 | 170 | Next.js アプリでは、サーバーサイドでのみ実行されるように設定してください: 171 | 172 | ```typescript 173 | // Initialize in a server component or API route 174 | import { createFirestoreClient } from "firebase-rest-firestore"; 175 | 176 | export async function getServerSideProps() { 177 | // Server-side only code 178 | const firestore = createFirestoreClient({ 179 | projectId: process.env.FIREBASE_PROJECT_ID, 180 | privateKey: process.env.FIREBASE_PRIVATE_KEY, 181 | clientEmail: process.env.FIREBASE_CLIENT_EMAIL, 182 | }); 183 | 184 | const data = await firestore.query("collection", [ 185 | /* your filters */ 186 | ]); 187 | 188 | return { 189 | props: { 190 | data: JSON.parse(JSON.stringify(data)), 191 | }, 192 | }; 193 | } 194 | ``` 195 | 196 | ## Cloudflare Workers 環境での使用 197 | 198 | ```typescript 199 | import { createFirestoreClient } from "firebase-rest-firestore"; 200 | 201 | export default { 202 | async fetch(request, env) { 203 | const firestore = createFirestoreClient({ 204 | projectId: env.FIREBASE_PROJECT_ID, 205 | privateKey: env.FIREBASE_PRIVATE_KEY, 206 | clientEmail: env.FIREBASE_CLIENT_EMAIL, 207 | }); 208 | 209 | // APIロジックの実装... 210 | const data = await firestore.query("collection", [ 211 | /* your filters */ 212 | ]); 213 | 214 | return new Response(JSON.stringify(data), { 215 | headers: { "Content-Type": "application/json" }, 216 | }); 217 | }, 218 | }; 219 | ``` 220 | 221 | ## クイックスタート 222 | 223 | ```typescript 224 | import { createFirestoreClient } from "firebase-rest-firestore"; 225 | 226 | // 設定オブジェクトでクライアントを初期化 227 | const firestore = createFirestoreClient({ 228 | projectId: "your-project-id", 229 | privateKey: "your-private-key", 230 | clientEmail: "your-client-email", 231 | }); 232 | 233 | // ドキュメントの追加 234 | const newDoc = await firestore.add("collection", { 235 | name: "テストドキュメント", 236 | value: 100, 237 | }); 238 | 239 | // ドキュメントの取得 240 | const doc = await firestore.get("collection", newDoc.id); 241 | 242 | // ドキュメントの更新 243 | await firestore.update("collection", newDoc.id, { value: 200 }); 244 | 245 | // ドキュメントのクエリ 246 | const querySnapshot = await firestore 247 | .collection("games") 248 | .where("score", ">", 50) 249 | .where("active", "==", true) 250 | .orderBy("score", "desc") 251 | .limit(10) 252 | .get(); 253 | 254 | const games = []; 255 | querySnapshot.forEach(doc => { 256 | games.push({ 257 | id: doc.id, 258 | ...doc.data(), 259 | }); 260 | }); 261 | console.log("Games with score > 50:", games); 262 | 263 | // ドキュメントの削除 264 | await firestore.delete("collection", newDoc.id); 265 | ``` 266 | 267 | ## 設定 268 | 269 | Firestore の権限を持つ Firebase サービスアカウントが必要です: 270 | 271 | ```typescript 272 | createFirestoreClient({ 273 | projectId: "your-project-id", 274 | privateKey: "your-private-key", // エスケープされた改行(\\n)を含む場合、自動的にフォーマットされます 275 | clientEmail: "your-client-email", 276 | }); 277 | ``` 278 | 279 | ## API リファレンス 280 | 281 | ### add(collectionName, data) 282 | 283 | コレクションに新しいドキュメントを追加します。 284 | 285 | パラメータ: 286 | 287 | - `collectionName`: コレクション名 288 | - `data`: 追加するドキュメントデータ 289 | 290 | 戻り値: 自動生成された ID を持つ追加されたドキュメント。 291 | 292 | ## ライセンス 293 | 294 | MIT 295 | -------------------------------------------------------------------------------- /src/utils/path.ts: -------------------------------------------------------------------------------- 1 | import { FirestoreConfig } from "../types"; 2 | 3 | /** 4 | * Utility class for constructing Firestore URIs 5 | * Consistently handles different types of paths and operations 6 | */ 7 | export class FirestorePath { 8 | private projectId: string; 9 | private databaseId: string; 10 | private useEmulator: boolean = false; 11 | private emulatorHost: string = "localhost"; 12 | private emulatorPort: number = 8080; 13 | private debug: boolean = false; 14 | 15 | /** 16 | * Constructor 17 | */ 18 | constructor(config: FirestoreConfig, debug: boolean = false) { 19 | this.projectId = config.projectId; 20 | this.databaseId = config.databaseId || "(default)"; 21 | this.debug = debug; 22 | 23 | if (config.useEmulator) { 24 | this.useEmulator = true; 25 | this.emulatorHost = config.emulatorHost || "localhost"; 26 | this.emulatorPort = config.emulatorPort || 8080; 27 | } 28 | } 29 | 30 | /** 31 | * Get Firestore base URL (without document path) 32 | */ 33 | getBasePath(): string { 34 | const baseUrl = this.useEmulator 35 | ? `http://${this.emulatorHost}:${this.emulatorPort}/v1` 36 | : "https://firestore.googleapis.com/v1"; 37 | 38 | const path = `${baseUrl}/projects/${this.projectId}/databases/${this.databaseId}/documents`; 39 | 40 | if (this.debug) { 41 | console.log(`Generated base path: ${path}`); 42 | } 43 | 44 | return path; 45 | } 46 | 47 | /** 48 | * Get base URL + collection path for a collection root 49 | * @param path Collection path (ex: "users" or "users/uid/posts") 50 | */ 51 | getCollectionPath(path: string): string { 52 | // Remove leading and trailing slashes 53 | const cleanPath = path.replace(/^\/+|\/+$/g, ''); 54 | 55 | const fullPath = `${this.getBasePath()}/${cleanPath}`; 56 | 57 | if (this.debug) { 58 | console.log(`Generated collection path: ${fullPath}`); 59 | } 60 | 61 | return fullPath; 62 | } 63 | 64 | /** 65 | * Get the complete URL for a document 66 | * @param collectionPath Collection path 67 | * @param documentId Document ID 68 | */ 69 | getDocumentPath(collectionPath: string, documentId: string): string { 70 | const cleanCollectionPath = collectionPath.replace(/^\/+|\/+$/g, ''); 71 | const path = `${this.getBasePath()}/${cleanCollectionPath}/${documentId}`; 72 | 73 | if (this.debug) { 74 | console.log(`Generated document path: ${path}`); 75 | } 76 | 77 | return path; 78 | } 79 | 80 | /** 81 | * Get URL for query execution 82 | * @param path Collection path (ex: "users" or "users/uid/posts") 83 | * @returns URL for query execution, collection ID, and parent path (if needed) 84 | */ 85 | getQueryPath(path: string): { 86 | url: string; 87 | collectionId: string; 88 | parentPath?: string; 89 | } { 90 | // パスをセグメントに分割 91 | const segments = path.replace(/^\/+|\/+$/g, '').split('/'); 92 | 93 | // 単一コレクションの場合 94 | if (segments.length === 1) { 95 | const url = `${this.getBasePath()}:runQuery`; 96 | 97 | if (this.debug) { 98 | console.log(`Generated query URL (single collection): ${url}`); 99 | console.log(`Collection ID: ${segments[0]}`); 100 | } 101 | 102 | return { 103 | url, 104 | collectionId: segments[0] 105 | }; 106 | } 107 | 108 | // ネストしたコレクションパスの場合 (例: "users/uid/posts") 109 | const collectionId = segments[segments.length - 1]; 110 | const parentSegments = segments.slice(0, -1); 111 | const parentPath = parentSegments.join('/'); 112 | 113 | // ベースURLでネストしたドキュメントまでのパスを取得 114 | const url = `${this.getBasePath()}:runQuery`; 115 | 116 | if (this.debug) { 117 | console.log(`Generated query URL (nested collection): ${url}`); 118 | console.log(`Collection ID: ${collectionId}`); 119 | console.log(`Parent path: ${parentPath}`); 120 | } 121 | 122 | return { 123 | url, 124 | collectionId, 125 | parentPath 126 | }; 127 | } 128 | 129 | /** 130 | * Get reference path for parent document (for query construction) 131 | * @param parentPath Parent document path 132 | */ 133 | getParentReference(parentPath: string): string { 134 | return `projects/${this.projectId}/databases/${this.databaseId}/documents/${parentPath}`; 135 | } 136 | 137 | /** 138 | * Get URL for runQuery 139 | * @param collectionPath Collection path 140 | * @returns URL for executing runQuery 141 | */ 142 | getRunQueryPath(collectionPath: string): string { 143 | // コレクションパス情報を取得 144 | const { collectionId, parentPath } = this.getQueryPath(collectionPath); 145 | 146 | // parentPathがある場合は、親ドキュメントパスを使用してURLを作成 147 | if (parentPath) { 148 | // getBasePathからベースURLを取得 149 | const baseUrl = this.getBasePath().replace(/\/documents$/, ''); 150 | 151 | // 親ドキュメントパスを含むrunQueryのURL 152 | const runQueryUrl = `${baseUrl}/documents/${parentPath}:runQuery`; 153 | 154 | if (this.debug) { 155 | console.log(`Generated runQuery URL for nested collection: ${runQueryUrl}`); 156 | console.log(`Collection ID: ${collectionId}`); 157 | } 158 | 159 | return runQueryUrl; 160 | } 161 | 162 | // トップレベルコレクションの場合は、ベースパスを使用 163 | const baseUrl = this.getBasePath(); 164 | const runQueryUrl = `${baseUrl}:runQuery`; 165 | 166 | if (this.debug) { 167 | console.log(`Generated runQuery URL for top-level collection: ${runQueryUrl}`); 168 | console.log(`Collection ID: ${collectionId}`); 169 | } 170 | 171 | return runQueryUrl; 172 | } 173 | } 174 | 175 | /** 176 | * Create an instance of FirestorePath class 177 | * @param config Firestore configuration 178 | * @param debug Debug mode 179 | */ 180 | export function createFirestorePath(config: FirestoreConfig, debug: boolean = false): FirestorePath { 181 | return new FirestorePath(config, debug); 182 | } 183 | 184 | /** 185 | * Get Firestore base path URL (without path) 186 | * @param projectId Project ID 187 | * @param databaseId Database ID (defaults to default) 188 | * @param config Firestore configuration (for emulator settings) 189 | * @returns Firestore base path URL (without path) 190 | */ 191 | export function getFirestoreBasePath( 192 | projectId: string, 193 | databaseId?: string, 194 | config?: FirestoreConfig 195 | ): string { 196 | // Use emulator URL for emulator mode 197 | if (config?.useEmulator) { 198 | const host = config.emulatorHost || "127.0.0.1"; 199 | const port = config.emulatorPort || 8080; 200 | 201 | return `http://${host}:${port}/v1/projects/${projectId}/databases/${ 202 | databaseId || "(default)" 203 | }/documents`; 204 | } 205 | 206 | // Use normal production environment URL 207 | return `https://firestore.googleapis.com/v1/projects/${projectId}/databases/${ 208 | databaseId || "(default)" 209 | }/documents`; 210 | } 211 | 212 | 213 | /** 214 | * Extract document ID from document path 215 | * @param path Document path 216 | * @returns Document ID 217 | */ 218 | export function getDocumentId(path: string): string { 219 | const parts = path.split("/"); 220 | return parts[parts.length - 1]; 221 | } 222 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Firebase REST Firestore 2 | 3 | [日本語版はこちら(Japanese Version)](./README.ja.md) 4 | 5 | Firebase Firestore REST API client for Edge runtime environments like Cloudflare Workers and Vercel Edge Functions. 6 | 7 | ## Features 8 | 9 | - Works in Edge runtime environments where Firebase Admin SDK is not available 10 | - Full CRUD operations support 11 | - TypeScript support 12 | - Token caching for better performance 13 | - Simple and intuitive API 14 | - Explicit configuration without hidden environment variable dependencies 15 | 16 | ## Installation 17 | 18 | ```bash 19 | npm install firebase-rest-firestore 20 | ``` 21 | 22 | ## Quick Start 23 | 24 | ```typescript 25 | import { createFirestoreClient } from "firebase-rest-firestore"; 26 | 27 | // Create a client with your configuration 28 | const firestore = createFirestoreClient({ 29 | projectId: "your-project-id", 30 | privateKey: "your-private-key", 31 | clientEmail: "your-client-email", 32 | }); 33 | 34 | // Add a document 35 | const newDoc = await firestore.add("collection", { 36 | name: "Test Document", 37 | value: 100, 38 | }); 39 | 40 | // Get a document 41 | const doc = await firestore.get("collection", newDoc.id); 42 | 43 | // Update a document 44 | await firestore.update("collection", newDoc.id, { value: 200 }); 45 | 46 | // Query documents 47 | const querySnapshot = await firestore 48 | .collection("games") 49 | .where("score", ">", 50) 50 | .where("active", "==", true) 51 | .orderBy("score", "desc") 52 | .limit(10) 53 | .get(); 54 | 55 | // Process query results 56 | const games = []; 57 | querySnapshot.forEach(doc => { 58 | games.push({ 59 | id: doc.id, 60 | ...doc.data(), 61 | }); 62 | }); 63 | console.log("Games with score > 50:", games); 64 | 65 | // Delete a document 66 | await firestore.delete("collection", newDoc.id); 67 | ``` 68 | 69 | ## Configuration 70 | 71 | The `FirestoreConfig` object requires the following properties: 72 | 73 | | Property | Description | 74 | | ----------- | ---------------------------- | 75 | | projectId | Firebase project ID | 76 | | privateKey | Service account private key | 77 | | clientEmail | Service account client email | 78 | 79 | ## API Reference 80 | 81 | ### FirestoreClient 82 | 83 | The main class for interacting with Firestore. 84 | 85 | #### collection(collectionPath).add(data) 86 | 87 | Creates a new document with an auto-generated ID in the specified collection. 88 | 89 | Parameters: 90 | 91 | - `data`: Document data to be added 92 | 93 | Returns: A reference to the created document. 94 | 95 | #### collection(collectionPath).doc(id?).set(data) 96 | 97 | Creates or overwrites a document with the specified ID. If no ID is provided, one will be auto-generated. 98 | 99 | Parameters: 100 | 101 | - `id` (optional): Document ID 102 | - `data`: Document data 103 | 104 | Returns: A promise that resolves when the set operation is complete. 105 | 106 | #### get(collectionName, documentId) 107 | 108 | Retrieves a document by ID. 109 | 110 | #### update(collectionName, documentId, data) 111 | 112 | Updates an existing document. 113 | 114 | #### delete(collectionName, documentId) 115 | 116 | Deletes a document. 117 | 118 | #### query(collectionName, options) 119 | 120 | Queries documents in a collection with filtering, ordering, and pagination. 121 | 122 | ### createFirestoreClient(config) 123 | 124 | Creates a new FirestoreClient instance with the provided configuration. 125 | 126 | #### add(collectionName, data) 127 | 128 | Adds a new document to the specified collection. 129 | 130 | Parameters: 131 | 132 | - `collectionName`: Name of the collection 133 | - `data`: Document data to be added 134 | 135 | Returns: The added document with auto-generated ID. 136 | 137 | ## Error Handling 138 | 139 | Firebase REST Firestore throws exceptions with appropriate error messages when API requests fail. Here's an example of error handling: 140 | 141 | ```typescript 142 | try { 143 | // Try to get a document 144 | const game = await firestore.get("games", "non-existent-id"); 145 | 146 | // If document doesn't exist, null is returned 147 | if (game === null) { 148 | console.log("Document not found"); 149 | return; 150 | } 151 | 152 | // Process document if it exists 153 | console.log("Fetched game:", game); 154 | } catch (error) { 155 | // Handle API errors (authentication, network, etc.) 156 | console.error("Firestore error:", error.message); 157 | } 158 | ``` 159 | 160 | Common error cases: 161 | 162 | - Authentication errors (invalid credentials) 163 | - Network errors 164 | - Invalid query parameters 165 | - Firestore rate limits 166 | 167 | ## Query Options Details 168 | 169 | The `query` method supports the following options for filtering, sorting, and paginating Firestore documents: 170 | 171 | ### where 172 | 173 | Specify multiple filter conditions. Each condition is an object with the following properties: 174 | 175 | - `field`: The field name to filter on 176 | - `op`: The comparison operator. Available values: 177 | - `EQUAL`: Equal to 178 | - `NOT_EQUAL`: Not equal to 179 | - `LESS_THAN`: Less than 180 | - `LESS_THAN_OR_EQUAL`: Less than or equal to 181 | - `GREATER_THAN`: Greater than 182 | - `GREATER_THAN_OR_EQUAL`: Greater than or equal to 183 | - `ARRAY_CONTAINS`: Array contains 184 | - `IN`: Equal to any of the specified values 185 | - `ARRAY_CONTAINS_ANY`: Array contains any of the specified values 186 | - `NOT_IN`: Not equal to any of the specified values 187 | - `value`: The value to compare against 188 | 189 | ```typescript 190 | // Query games with score > 50 and active = true 191 | const games = await firestore.query("games", { 192 | where: [ 193 | { field: "score", op: "GREATER_THAN", value: 50 }, 194 | { field: "active", op: "EQUAL", value: true }, 195 | ], 196 | }); 197 | ``` 198 | 199 | ### orderBy 200 | 201 | Specifies the field name to sort results by. Results are sorted in ascending order by default. 202 | 203 | ```typescript 204 | // Sort by creation time 205 | const games = await firestore.query("games", { 206 | orderBy: "createdAt", 207 | }); 208 | ``` 209 | 210 | ### limit 211 | 212 | Limits the maximum number of results returned. 213 | 214 | ```typescript 215 | // Get at most 10 documents 216 | const games = await firestore.query("games", { 217 | limit: 10, 218 | }); 219 | ``` 220 | 221 | ### offset 222 | 223 | Specifies the number of results to skip. Useful for pagination. 224 | 225 | ```typescript 226 | // Skip the first 20 results and get the next 10 227 | const games = await firestore.query("games", { 228 | offset: 20, 229 | limit: 10, 230 | }); 231 | ``` 232 | 233 | Example of a compound query: 234 | 235 | ```typescript 236 | // Get top 10 active games by score 237 | const topGames = await firestore.query("games", { 238 | where: [{ field: "active", op: "EQUAL", value: true }], 239 | orderBy: "score", // Sort by score 240 | limit: 10, 241 | }); 242 | ``` 243 | 244 | ## Edge Runtime Examples 245 | 246 | ### Cloudflare Workers 247 | 248 | ```typescript 249 | // Set these environment variables in wrangler.toml 250 | // FIREBASE_PROJECT_ID 251 | // FIREBASE_PRIVATE_KEY 252 | // FIREBASE_CLIENT_EMAIL 253 | 254 | import { createFirestoreClient } from "firebase-rest-firestore"; 255 | 256 | export default { 257 | async fetch(request, env, ctx) { 258 | // Load configuration from environment variables 259 | const firestore = createFirestoreClient({ 260 | projectId: env.FIREBASE_PROJECT_ID, 261 | privateKey: env.FIREBASE_PRIVATE_KEY.replace(/\\n/g, "\n"), 262 | clientEmail: env.FIREBASE_CLIENT_EMAIL, 263 | }); 264 | 265 | const url = new URL(request.url); 266 | const path = url.pathname; 267 | 268 | // Example API endpoint 269 | if (path === "/api/games" && request.method === "GET") { 270 | try { 271 | // Get active games 272 | const games = await firestore.query("games", { 273 | where: [{ field: "active", op: "EQUAL", value: true }], 274 | limit: 10, 275 | }); 276 | 277 | return new Response(JSON.stringify(games), { 278 | headers: { "Content-Type": "application/json" }, 279 | }); 280 | } catch (error) { 281 | return new Response(JSON.stringify({ error: error.message }), { 282 | status: 500, 283 | headers: { "Content-Type": "application/json" }, 284 | }); 285 | } 286 | } 287 | 288 | return new Response("Not found", { status: 404 }); 289 | }, 290 | }; 291 | ``` 292 | 293 | ### Vercel Edge Functions 294 | 295 | ```typescript 296 | // Set these environment variables in .env.local 297 | // FIREBASE_PROJECT_ID 298 | // FIREBASE_PRIVATE_KEY 299 | // FIREBASE_CLIENT_EMAIL 300 | 301 | import { createFirestoreClient } from "firebase-rest-firestore"; 302 | 303 | export const config = { 304 | runtime: "edge", 305 | }; 306 | 307 | export default async function handler(request) { 308 | // Load configuration from environment variables 309 | const firestore = createFirestoreClient({ 310 | projectId: process.env.FIREBASE_PROJECT_ID, 311 | privateKey: process.env.FIREBASE_PRIVATE_KEY.replace(/\\n/g, "\n"), 312 | clientEmail: process.env.FIREBASE_CLIENT_EMAIL, 313 | }); 314 | 315 | try { 316 | // Get the latest 10 documents 317 | const documents = await firestore.query("posts", { 318 | orderBy: "createdAt", 319 | limit: 10, 320 | }); 321 | 322 | return new Response(JSON.stringify(documents), { 323 | headers: { "Content-Type": "application/json" }, 324 | }); 325 | } catch (error) { 326 | return new Response(JSON.stringify({ error: error.message }), { 327 | status: 500, 328 | headers: { "Content-Type": "application/json" }, 329 | }); 330 | } 331 | } 332 | ``` 333 | 334 | ## Performance Considerations 335 | 336 | ### Token Caching 337 | 338 | Firebase REST Firestore caches JWT tokens to improve performance. By default, tokens are cached for 50 minutes (actual token expiry is 1 hour). This eliminates the need to generate a new token for each request, improving API request speed. 339 | 340 | ```typescript 341 | // Tokens are cached internally, so multiple requests 342 | // have minimal authentication overhead 343 | const doc1 = await firestore.get("collection", "doc1"); 344 | const doc2 = await firestore.get("collection", "doc2"); 345 | const doc3 = await firestore.get("collection", "doc3"); 346 | ``` 347 | 348 | ### Query Optimization 349 | 350 | When dealing with large amounts of data, consider the following: 351 | 352 | 1. **Set appropriate limits**: Always use the `limit` parameter to restrict the number of documents returned. 353 | 354 | 2. **Query only needed fields**: Future versions will add support for retrieving only specific fields. 355 | 356 | 3. **Create indexes**: For complex queries, create appropriate indexes in the Firebase console. 357 | 358 | 4. **Use pagination**: When retrieving large datasets, implement pagination using `offset` and `limit`. 359 | 360 | ### Edge Environment Considerations 361 | 362 | In edge environments, be aware of: 363 | 364 | 1. **Cold starts**: Initial execution has token generation overhead. 365 | 366 | 2. **Memory usage**: Be mindful of memory limits when processing large amounts of data. 367 | 368 | 3. **Timeouts**: Long-running queries may hit edge environment timeout limits. 369 | 370 | ## Limitations and Roadmap 371 | 372 | ### Current Limitations 373 | 374 | - **Batch operations**: The current version does not support batch processing for operating on multiple documents at once. 375 | - **Transactions**: Atomic transaction operations are not supported. 376 | - **Real-time listeners**: Due to the nature of REST APIs, real-time data synchronization is not supported. 377 | - **Subcollections**: The current version has limited direct support for nested subcollections. 378 | 379 | ### Future Roadmap 380 | 381 | The following features are planned for future versions: 382 | 383 | - Batch operations support 384 | - Basic transaction support 385 | - Improved subcollection support 386 | - More detailed query options (compound indexes, etc.) 387 | - Performance optimizations 388 | 389 | Please report feature requests and bugs via GitHub Issues. 390 | 391 | ## License 392 | 393 | MIT 394 | -------------------------------------------------------------------------------- /test/client.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach, afterEach } from "vitest"; 2 | import { 3 | FirestoreClient, 4 | createFirestoreClient, 5 | DocumentReference, 6 | } from "../src/client"; 7 | import { loadConfig, getTestCollectionName } from "./helpers"; 8 | 9 | /** 10 | * Note: Before running composite query tests, you need to create composite indices for the following fields: 11 | * - category + stock 12 | * - price + stock 13 | * - category + price 14 | * - category + tags (tags requires an array_contains type index) 15 | * 16 | * Create these manually from the Firestore console or deploy using Firebase CLI. 17 | * 18 | * Index creation example (firestore.indexes.json for Firebase CLI): 19 | * ``` 20 | * { 21 | * "indexes": [ 22 | * { 23 | * "collectionGroup": "test_indexed_collection", 24 | * "queryScope": "COLLECTION", 25 | * "fields": [ 26 | * { "fieldPath": "category", "order": "ASCENDING" }, 27 | * { "fieldPath": "price", "order": "ASCENDING" } 28 | * ] 29 | * }, 30 | * { 31 | * "collectionGroup": "test_indexed_collection", 32 | * "queryScope": "COLLECTION", 33 | * "fields": [ 34 | * { "fieldPath": "category", "order": "ASCENDING" }, 35 | * { "fieldPath": "stock", "order": "ASCENDING" } 36 | * ] 37 | * }, 38 | * { 39 | * "collectionGroup": "test_indexed_collection", 40 | * "queryScope": "COLLECTION", 41 | * "fields": [ 42 | * { "fieldPath": "price", "order": "ASCENDING" }, 43 | * { "fieldPath": "stock", "order": "ASCENDING" } 44 | * ] 45 | * }, 46 | * { 47 | * "collectionGroup": "test_indexed_collection", 48 | * "queryScope": "COLLECTION", 49 | * "fields": [ 50 | * { "fieldPath": "category", "order": "ASCENDING" }, 51 | * { "fieldPath": "tags", "arrayConfig": "CONTAINS" } 52 | * ] 53 | * } 54 | * ] 55 | * } 56 | * ``` 57 | */ 58 | 59 | // Fixed test collection name (for composite indices) 60 | const INDEXED_TEST_COLLECTION = "test_indexed_collection"; 61 | // Fixed collection name for collection group tests 62 | const NESTED_COLLECTION_NAME = "items"; 63 | 64 | describe("Firebase Rest Firestore", () => { 65 | let client: FirestoreClient; 66 | let testCollection: string; 67 | let createdIds: { collection: string; id: string }[] = []; 68 | let debugMode: boolean; 69 | 70 | beforeEach(() => { 71 | // Initialize client by loading configuration from environment variables 72 | const config = loadConfig(); 73 | client = createFirestoreClient(config); 74 | // Extract debug setting from config 75 | debugMode = config.debug || false; 76 | // Use dynamic collection names for normal tests 77 | testCollection = getTestCollectionName(); 78 | createdIds = []; 79 | }); 80 | 81 | afterEach(async () => { 82 | // Clean up documents created during tests 83 | for (const { collection, id } of createdIds) { 84 | try { 85 | await client.delete(collection, id); 86 | } catch (err) { 87 | console.error( 88 | `Clean up failed for document ${collection}/${id}: ${err}` 89 | ); 90 | } 91 | } 92 | }); 93 | 94 | // Basic client functionality test 95 | it("Client should initialize correctly", () => { 96 | expect(client).toBeDefined(); 97 | }); 98 | 99 | // Basic CRUD operations test 100 | it("Should be able to create, read, update, and delete documents", async () => { 101 | // Creation test 102 | const testData = { 103 | name: "Test Item", 104 | value: 123, 105 | active: true, 106 | }; 107 | 108 | const createdDoc = await client.add(testCollection, testData); 109 | createdIds.push({ collection: testCollection, id: createdDoc.id }); 110 | 111 | expect(createdDoc).toBeDefined(); 112 | expect(createdDoc.id).toBeDefined(); 113 | expect(createdDoc.name).toBe(testData.name); 114 | expect(createdDoc.value).toBe(testData.value); 115 | expect(createdDoc.active).toBe(testData.active); 116 | 117 | // Reading test 118 | const fetchedDoc = await client.get(testCollection, createdDoc.id); 119 | expect(fetchedDoc).toBeDefined(); 120 | expect(fetchedDoc?.id).toBe(createdDoc.id); 121 | expect(fetchedDoc?.name).toBe(testData.name); 122 | expect(fetchedDoc?.value).toBe(testData.value); 123 | expect(fetchedDoc?.active).toBe(testData.active); 124 | 125 | // Update test 126 | const updateData = { 127 | name: "Updated Item", 128 | active: false, 129 | }; 130 | 131 | const updatedDoc = await client.update( 132 | testCollection, 133 | createdDoc.id, 134 | updateData 135 | ); 136 | 137 | expect(updatedDoc).toBeDefined(); 138 | expect(updatedDoc.id).toBe(createdDoc.id); 139 | expect(updatedDoc.name).toBe(updateData.name); 140 | expect(updatedDoc.value).toBe(testData.value); 141 | expect(updatedDoc.active).toBe(updateData.active); 142 | 143 | // Update confirmation test 144 | const fetchedUpdatedDoc = await client.get(testCollection, createdDoc.id); 145 | expect(fetchedUpdatedDoc?.name).toBe(updateData.name); 146 | expect(fetchedUpdatedDoc?.value).toBe(testData.value); 147 | expect(fetchedUpdatedDoc?.active).toBe(updateData.active); 148 | 149 | // Delete test 150 | await client.delete(testCollection, createdDoc.id); 151 | const deletedDoc = await client.get(testCollection, createdDoc.id); 152 | expect(deletedDoc).toBeNull(); 153 | 154 | // IDs of deleted documents don't need cleanup 155 | createdIds = createdIds.filter( 156 | doc => !(doc.collection === testCollection && doc.id === createdDoc.id) 157 | ); 158 | }); 159 | 160 | // Reference API test 161 | it("Should be able to operate on documents using the reference API", async () => { 162 | // Collection reference 163 | const collRef = client.collection(testCollection); 164 | expect(collRef).toBeDefined(); 165 | 166 | // Add document 167 | const testData = { name: "Reference Test", count: 100 }; 168 | const docRef = await collRef.add(testData); 169 | createdIds.push({ collection: testCollection, id: docRef.id }); 170 | 171 | expect(docRef).toBeDefined(); 172 | expect(docRef.id).toBeDefined(); 173 | 174 | // Get document 175 | const snapshot = await docRef.get(); 176 | expect(snapshot.exists).toBe(true); 177 | expect(snapshot.data()).toMatchObject(testData); 178 | 179 | // Update document 180 | await docRef.update({ 181 | name: "Updated Document", 182 | count: 200, 183 | }); 184 | 185 | // Confirm update 186 | const updatedSnapshot = await docRef.get(); 187 | expect(updatedSnapshot.data()?.name).toBe("Updated Document"); 188 | expect(updatedSnapshot.data()?.count).toBe(200); 189 | 190 | // Delete document 191 | await docRef.delete(); 192 | 193 | // Confirm deletion 194 | const deletedSnapshot = await docRef.get(); 195 | expect(deletedSnapshot.exists).toBe(false); 196 | 197 | // IDs of deleted documents don't need cleanup 198 | createdIds = createdIds.filter( 199 | doc => !(doc.collection === testCollection && doc.id === docRef.id) 200 | ); 201 | }); 202 | 203 | // Simple query test 204 | it("Should be able to execute basic queries", async () => { 205 | // Add multiple test data items 206 | const testData = [ 207 | { category: "A", price: 100, stock: true }, 208 | { category: "B", price: 200, stock: false }, 209 | { category: "A", price: 300, stock: true }, 210 | ]; 211 | 212 | for (const data of testData) { 213 | const doc = await client.add(testCollection, data); 214 | createdIds.push({ collection: testCollection, id: doc.id }); 215 | } 216 | 217 | // Limit to single-condition filtering test 218 | if (debugMode) console.log("Starting single condition filtering test"); 219 | try { 220 | const filteredResults = await client 221 | .collection(testCollection) 222 | .where("category", "==", "A") 223 | .get(); 224 | 225 | if (debugMode) console.log("Filtering results:", filteredResults.docs.length); 226 | expect(filteredResults.docs.length).toBe(2); 227 | expect( 228 | filteredResults.docs.every(doc => doc.data()?.category === "A") 229 | ).toBe(true); 230 | } catch (error) { 231 | console.error("Filtering test failed:", error); 232 | throw error; 233 | } 234 | }); 235 | 236 | // Multiple filter test 237 | it("Should be able to execute queries with multiple filter conditions", async () => { 238 | // Use fixed collection with indexes 239 | const indexedCollection = INDEXED_TEST_COLLECTION; 240 | 241 | // Add multiple test data items 242 | const testData = [ 243 | { category: "A", price: 100, stock: true, tags: ["sale", "new"] }, 244 | { category: "B", price: 200, stock: false, tags: ["sale"] }, 245 | { category: "A", price: 300, stock: true, tags: ["premium"] }, 246 | { category: "C", price: 150, stock: true, tags: ["sale", "limited"] }, 247 | { category: "A", price: 50, stock: false, tags: ["clearance"] }, 248 | ]; 249 | 250 | for (const data of testData) { 251 | const doc = await client.add(indexedCollection, data); 252 | createdIds.push({ collection: indexedCollection, id: doc.id }); 253 | } 254 | 255 | if (debugMode) console.log("Starting multiple filter test"); 256 | try { 257 | // Category A and in stock 258 | const filteredResults1 = await client 259 | .collection(indexedCollection) 260 | .where("category", "==", "A") 261 | .where("stock", "==", true) 262 | .get(); 263 | 264 | if (debugMode) console.log("Category A and in stock results:", filteredResults1.docs.length); 265 | expect(filteredResults1.docs.length).toBe(2); 266 | filteredResults1.docs.forEach(doc => { 267 | const data = doc.data(); 268 | expect(data?.category).toBe("A"); 269 | expect(data?.stock).toBe(true); 270 | }); 271 | 272 | // Price greater than 100 and in stock 273 | const filteredResults2 = await client 274 | .collection(indexedCollection) 275 | .where("price", ">", 100) 276 | .where("stock", "==", true) 277 | .get(); 278 | 279 | if (debugMode) console.log("Price > 100 and in stock results:", filteredResults2.docs.length); 280 | expect(filteredResults2.docs.length).toBe(2); 281 | filteredResults2.docs.forEach(doc => { 282 | const data = doc.data(); 283 | expect(data?.price).toBeGreaterThan(100); 284 | expect(data?.stock).toBe(true); 285 | }); 286 | 287 | // Category A and price less than or equal to 100 288 | const filteredResults3 = await client 289 | .collection(indexedCollection) 290 | .where("category", "==", "A") 291 | .where("price", "<=", 100) 292 | .get(); 293 | 294 | if (debugMode) console.log( 295 | "Category A and price <= 100 results:", 296 | filteredResults3.docs.length 297 | ); 298 | expect(filteredResults3.docs.length).toBe(2); 299 | filteredResults3.docs.forEach(doc => { 300 | const data = doc.data(); 301 | expect(data?.category).toBe("A"); 302 | expect(data?.price).toBeLessThanOrEqual(100); 303 | }); 304 | 305 | // Documents containing 'sale' tag 306 | const filteredResults4 = await client 307 | .collection(indexedCollection) 308 | .where("tags", "array-contains", "sale") 309 | .get(); 310 | 311 | if (debugMode) console.log("Results containing sale tag:", filteredResults4.docs.length); 312 | expect(filteredResults4.docs.length).toBe(3); 313 | filteredResults4.docs.forEach(doc => { 314 | const data = doc.data(); 315 | expect(data?.tags).toContain("sale"); 316 | }); 317 | 318 | // Category A and containing 'sale' tag 319 | const filteredResults5 = await client 320 | .collection(indexedCollection) 321 | .where("category", "==", "A") 322 | .where("tags", "array-contains", "sale") 323 | .get(); 324 | 325 | if (debugMode) console.log( 326 | "Category A and containing sale tag results:", 327 | filteredResults5.docs.length 328 | ); 329 | expect(filteredResults5.docs.length).toBe(1); 330 | filteredResults5.docs.forEach(doc => { 331 | const data = doc.data(); 332 | expect(data?.category).toBe("A"); 333 | expect(data?.tags).toContain("sale"); 334 | }); 335 | 336 | // Documents with category 'A' or 'B' (in operator) 337 | const filteredResults6 = await client 338 | .collection(indexedCollection) 339 | .where("category", "in", ["A", "B"]) 340 | .get(); 341 | 342 | if (debugMode) console.log("Results with category A or B:", filteredResults6.docs.length); 343 | expect(filteredResults6.docs.length).toBe(4); 344 | filteredResults6.docs.forEach(doc => { 345 | const data = doc.data(); 346 | expect(["A", "B"]).toContain(data?.category); 347 | }); 348 | 349 | // Documents with category not 'A' or 'B' (not-in operator) 350 | const filteredResults7 = await client 351 | .collection(indexedCollection) 352 | .where("category", "not-in", ["A", "B"]) 353 | .get(); 354 | 355 | if (debugMode) console.log( 356 | "Results with category not A or B:", 357 | filteredResults7.docs.length 358 | ); 359 | expect(filteredResults7.docs.length).toBe(1); 360 | filteredResults7.docs.forEach(doc => { 361 | const data = doc.data(); 362 | expect(["A", "B"]).not.toContain(data?.category); 363 | }); 364 | } catch (error) { 365 | console.error("Multiple filter test failed:", error); 366 | throw error; 367 | } 368 | }); 369 | 370 | // Nested collection test 371 | it("Should be able to operate on documents in nested collections", async () => { 372 | // Create parent document 373 | const parentData = { name: "Parent Document" }; 374 | const parentDoc = await client.add(testCollection, parentData); 375 | createdIds.push({ collection: testCollection, id: parentDoc.id }); 376 | 377 | // Get subcollection reference 378 | const subCollectionRef = client.collection( 379 | `${testCollection}/${parentDoc.id}/${NESTED_COLLECTION_NAME}` 380 | ); 381 | 382 | // Add data to subcollection 383 | const subCollectionData = [ 384 | { name: "Sub Item 1", value: 100 }, 385 | { name: "Sub Item 2", value: 200 }, 386 | { name: "Sub Item 3", value: 300 }, 387 | ]; 388 | 389 | // Add documents to subcollection 390 | const subDocRefs: DocumentReference[] = []; 391 | for (const data of subCollectionData) { 392 | const subDoc = await subCollectionRef.add(data); 393 | subDocRefs.push(subDoc); 394 | const nestedPath = `${testCollection}/${parentDoc.id}/${NESTED_COLLECTION_NAME}`; 395 | createdIds.push({ collection: nestedPath, id: subDoc.id }); 396 | } 397 | 398 | // Get data from subcollection 399 | const subCollectionSnapshot = await subCollectionRef.get(); 400 | expect(subCollectionSnapshot.docs.length).toBe(3); 401 | 402 | // Get specific document from subcollection 403 | const subDocSnapshot = await subDocRefs[0].get(); 404 | expect(subDocSnapshot.exists).toBe(true); 405 | expect(subDocSnapshot.data()?.name).toBe("Sub Item 1"); 406 | 407 | // Test subcollection query 408 | const querySnapshot = await subCollectionRef.where("value", ">", 150).get(); 409 | 410 | expect(querySnapshot.docs.length).toBe(2); 411 | querySnapshot.docs.forEach(doc => { 412 | expect(doc.data()?.value).toBeGreaterThan(150); 413 | }); 414 | 415 | // Update subdocument 416 | await subDocRefs[0].update({ value: 150 }); 417 | const updatedSubDoc = await subDocRefs[0].get(); 418 | expect(updatedSubDoc.data()?.value).toBe(150); 419 | 420 | // Delete subdocument and remove from cleanup list 421 | const subDocIdToDelete = subDocRefs[2].id; 422 | const nestedPathToDelete = `${testCollection}/${parentDoc.id}/${NESTED_COLLECTION_NAME}`; 423 | await subDocRefs[2].delete(); 424 | 425 | // Confirm deletion 426 | const deletedDocSnapshot = await client.get( 427 | nestedPathToDelete, 428 | subDocIdToDelete 429 | ); 430 | expect(deletedDocSnapshot).toBeNull(); 431 | 432 | // Remove deleted document from cleanup list 433 | createdIds = createdIds.filter( 434 | doc => 435 | !(doc.collection === nestedPathToDelete && doc.id === subDocIdToDelete) 436 | ); 437 | }); 438 | 439 | // Test updating nested object fields 440 | it("Should be able to update nested object fields using dot notation", async () => { 441 | // Create test data with nested objects 442 | const testData = { 443 | name: "Test User", 444 | profile: { 445 | age: 30, 446 | job: "Engineer", 447 | address: { 448 | prefecture: "Tokyo", 449 | city: "Shinjuku", 450 | }, 451 | }, 452 | favorites: { 453 | food: "Ramen", 454 | color: "Blue", 455 | sports: "Soccer", 456 | }, 457 | }; 458 | 459 | // Create document 460 | const createdDoc = await client.add(testCollection, testData); 461 | createdIds.push({ collection: testCollection, id: createdDoc.id }); 462 | expect(createdDoc).toBeDefined(); 463 | expect(createdDoc.profile.age).toBe(30); 464 | expect(createdDoc.favorites.color).toBe("Blue"); 465 | 466 | // Update nested fields (dot notation) 467 | const updateData = { 468 | "profile.age": 31, 469 | "favorites.color": "Red", 470 | "profile.address.city": "Shibuya", 471 | }; 472 | 473 | const updatedDoc = await client.update( 474 | testCollection, 475 | createdDoc.id, 476 | updateData 477 | ); 478 | 479 | // Verify update results 480 | expect(updatedDoc).toBeDefined(); 481 | expect(updatedDoc.profile.age).toBe(31); 482 | expect(updatedDoc.favorites.color).toBe("Red"); 483 | expect(updatedDoc.profile.address.city).toBe("Shibuya"); 484 | 485 | // Verify unchanged fields are preserved 486 | expect(updatedDoc.name).toBe("Test User"); 487 | expect(updatedDoc.profile.job).toBe("Engineer"); 488 | expect(updatedDoc.profile.address.prefecture).toBe("Tokyo"); 489 | expect(updatedDoc.favorites.food).toBe("Ramen"); 490 | expect(updatedDoc.favorites.sports).toBe("Soccer"); 491 | 492 | // Test updating non-existent nested paths 493 | const newNestedData = { 494 | "settings.theme": "Dark", 495 | "profile.skills": ["JavaScript", "TypeScript"], 496 | }; 497 | 498 | const newUpdatedDoc = await client.update( 499 | testCollection, 500 | createdDoc.id, 501 | newNestedData 502 | ); 503 | 504 | // Verify new nested fields are created 505 | expect(newUpdatedDoc.settings.theme).toBe("Dark"); 506 | expect(newUpdatedDoc.profile.skills).toEqual(["JavaScript", "TypeScript"]); 507 | 508 | // Verify existing data is preserved 509 | expect(newUpdatedDoc.profile.age).toBe(31); 510 | expect(newUpdatedDoc.favorites.color).toBe("Red"); 511 | 512 | // Test updates using DocumentReference API 513 | const docRef = client.collection(testCollection).doc(createdDoc.id); 514 | await docRef.update({ 515 | "favorites.color": "Green", 516 | "profile.address.prefecture": "Osaka", 517 | }); 518 | 519 | // Verify update results 520 | const finalDoc = await docRef.get(); 521 | const finalData = finalDoc.data(); 522 | expect(finalData?.favorites.color).toBe("Green"); 523 | expect(finalData?.profile.address.prefecture).toBe("Osaka"); 524 | expect(finalData?.profile.address.city).toBe("Shibuya"); 525 | }); 526 | 527 | // Collection group test 528 | it("Should be able to perform cross-collection queries using collection groups", async () => { 529 | // Parent collection 1 530 | const parentCollection1 = `${getTestCollectionName()}_parent1`; 531 | 532 | // Parent collection 2 533 | const parentCollection2 = `${getTestCollectionName()}_parent2`; 534 | 535 | // Create parent document 1 536 | const parent1Data = { name: "Parent Document 1" }; 537 | const parent1Doc = await client.add(parentCollection1, parent1Data); 538 | createdIds.push({ collection: parentCollection1, id: parent1Doc.id }); 539 | 540 | // Create parent document 2 541 | const parent2Data = { name: "Parent Document 2" }; 542 | const parent2Doc = await client.add(parentCollection2, parent2Data); 543 | createdIds.push({ collection: parentCollection2, id: parent2Doc.id }); 544 | 545 | // Add data to parent 1's subcollection 546 | const subColl1Ref = client.collection( 547 | `${parentCollection1}/${parent1Doc.id}/${NESTED_COLLECTION_NAME}` 548 | ); 549 | const subColl1Data = [ 550 | { category: "A", price: 100 }, 551 | { category: "B", price: 200 }, 552 | ]; 553 | 554 | for (const data of subColl1Data) { 555 | const doc = await subColl1Ref.add(data); 556 | const path = `${parentCollection1}/${parent1Doc.id}/${NESTED_COLLECTION_NAME}`; 557 | createdIds.push({ collection: path, id: doc.id }); 558 | } 559 | 560 | // Add data to parent 2's subcollection 561 | const subColl2Ref = client.collection( 562 | `${parentCollection2}/${parent2Doc.id}/${NESTED_COLLECTION_NAME}` 563 | ); 564 | const subColl2Data = [ 565 | { category: "A", price: 300 }, 566 | { category: "C", price: 400 }, 567 | ]; 568 | 569 | for (const data of subColl2Data) { 570 | const doc = await subColl2Ref.add(data); 571 | const path = `${parentCollection2}/${parent2Doc.id}/${NESTED_COLLECTION_NAME}`; 572 | createdIds.push({ collection: path, id: doc.id }); 573 | } 574 | 575 | // Execute query across all subcollections using collection group 576 | if (debugMode) console.log("Starting collection group test"); 577 | try { 578 | // Find category A items from all "items" collections 579 | const groupQuery = await client 580 | .collectionGroup(NESTED_COLLECTION_NAME) 581 | .where("category", "==", "A") 582 | .get(); 583 | 584 | if (debugMode) console.log("Collection group query results:", groupQuery.docs.length); 585 | expect(groupQuery.docs.length).toBe(2); 586 | 587 | // Verify all results are category A 588 | groupQuery.docs.forEach(doc => { 589 | expect(doc.data()?.category).toBe("A"); 590 | }); 591 | 592 | // Query sorted by price 593 | const sortedQuery = await client 594 | .collectionGroup(NESTED_COLLECTION_NAME) 595 | .orderBy("price", "desc") 596 | .get(); 597 | 598 | if (debugMode) console.log("Sorted results:", sortedQuery.docs.length); 599 | expect(sortedQuery.docs.length).toBe(4); 600 | 601 | // Verify descending order 602 | let lastPrice = Infinity; 603 | sortedQuery.docs.forEach(doc => { 604 | const currentPrice = doc.data()?.price; 605 | expect(currentPrice).toBeLessThanOrEqual(lastPrice); 606 | lastPrice = currentPrice; 607 | }); 608 | 609 | // Complex conditional query 610 | const complexQuery = await client 611 | .collectionGroup(NESTED_COLLECTION_NAME) 612 | .where("category", "in", ["A", "B"]) 613 | .where("price", ">", 150) 614 | .get(); 615 | 616 | if (debugMode) console.log("Complex condition results:", complexQuery.docs.length); 617 | expect(complexQuery.docs.length).toBe(2); 618 | 619 | complexQuery.docs.forEach(doc => { 620 | const data = doc.data(); 621 | expect(["A", "B"]).toContain(data?.category); 622 | expect(data?.price).toBeGreaterThan(150); 623 | }); 624 | } catch (error) { 625 | console.error("Collection group test failed:", error); 626 | throw error; 627 | } 628 | }); 629 | 630 | // Test the doc method 631 | it("Should create document references with valid paths and reject invalid paths", () => { 632 | // Valid document paths (even number of segments) 633 | const validPaths = [ 634 | "collection/doc1", 635 | "collection/doc1/subcollection/subdoc", 636 | "collection/doc1/subcollection/subdoc/deepcollection/deepdoc" 637 | ]; 638 | 639 | for (const path of validPaths) { 640 | // This should not throw an error 641 | const docRef = client.doc(path); 642 | expect(docRef).toBeDefined(); 643 | expect(docRef.id).toBe(path.split("/").pop()); 644 | } 645 | 646 | // Invalid document paths (odd number of segments) 647 | const invalidPaths = [ 648 | "collection", 649 | "collection/doc1/subcollection", 650 | "collection/doc1/subcollection/subdoc/deepcollection" 651 | ]; 652 | 653 | for (const path of invalidPaths) { 654 | // This should throw an error 655 | expect(() => client.doc(path)).toThrow( 656 | "Invalid document path. Document path must point to a document, not a collection." 657 | ); 658 | } 659 | 660 | // Test that document reference has the correct path components 661 | const complexPath = "users/user123/posts/post456"; 662 | const docRef = client.doc(complexPath); 663 | 664 | expect(docRef.id).toBe("post456"); 665 | expect(docRef.path).toBe(complexPath); 666 | 667 | // Verify the parent collection is correctly identified 668 | const collectionRef = docRef.parent; 669 | expect(collectionRef.path).toBe("users/user123/posts"); 670 | }); 671 | }); 672 | -------------------------------------------------------------------------------- /src/client.ts: -------------------------------------------------------------------------------- 1 | import { FirestoreConfig, FirestoreResponse, QueryOptions } from "./types"; 2 | import { getFirestoreToken } from "./utils/auth"; 3 | import { 4 | convertFromFirestoreDocument, 5 | convertToFirestoreDocument, 6 | convertToFirestoreValue, 7 | } from "./utils/converter"; 8 | import { getFirestoreBasePath } from "./utils/path"; 9 | import { formatPrivateKey } from "./utils/config"; 10 | import { FirestorePath, createFirestorePath } from "./utils/path"; 11 | 12 | /** 13 | * Firestore client class 14 | */ 15 | export class FirestoreClient { 16 | private token: string | null = null; 17 | private tokenExpiry: number = 0; 18 | private config: FirestoreConfig; 19 | private configChecked: boolean = false; 20 | private debug: boolean = false; 21 | private pathUtil: FirestorePath; 22 | 23 | /** 24 | * Constructor 25 | * @param config Firestore configuration object 26 | */ 27 | constructor(config: FirestoreConfig) { 28 | this.config = config; 29 | this.pathUtil = createFirestorePath(config, config.debug || false); 30 | this.debug = !!config.debug; 31 | 32 | // Log configuration if debug is enabled 33 | if (this.debug) { 34 | console.log( 35 | "Firestore client initialized with config:", 36 | JSON.stringify(this.config, null, 2) 37 | ); 38 | } 39 | } 40 | 41 | /** 42 | * Check configuration parameters 43 | * @private 44 | */ 45 | private checkConfig() { 46 | if (this.configChecked) { 47 | return; 48 | } 49 | 50 | // 必須パラメータのチェック 51 | const requiredParams: Array = ["projectId"]; 52 | 53 | // Only require auth parameters when not using emulator 54 | if (!this.config.useEmulator) { 55 | requiredParams.push("privateKey", "clientEmail"); 56 | } 57 | 58 | const missingParams = requiredParams.filter(param => !this.config[param]); 59 | if (missingParams.length > 0) { 60 | throw new Error( 61 | `Missing required Firestore configuration parameters: ${missingParams.join( 62 | ", " 63 | )}` 64 | ); 65 | } 66 | 67 | this.configChecked = true; 68 | } 69 | 70 | /** 71 | * Get authentication token (with caching) 72 | */ 73 | private async getToken(): Promise { 74 | // Check settings before operation 75 | this.checkConfig(); 76 | 77 | // In emulator mode, we don't need a token 78 | if (this.config.useEmulator) { 79 | if (this.debug) { 80 | console.log("Emulator mode: skipping token generation"); 81 | } 82 | return "emulator-fake-token"; 83 | } 84 | 85 | const now = Date.now(); 86 | // トークンが期限切れか未取得の場合は新しく取得 87 | if (!this.token || now >= this.tokenExpiry) { 88 | if (this.debug) { 89 | console.log("Generating new auth token"); 90 | } 91 | this.token = await getFirestoreToken(this.config); 92 | // 50分後に期限切れとする(実際は1時間) 93 | this.tokenExpiry = now + 50 * 60 * 1000; 94 | } 95 | return this.token; 96 | } 97 | 98 | /** 99 | * Prepare request headers 100 | * @param additionalHeaders Additional headers 101 | * @returns Prepared headers object 102 | * @private 103 | */ 104 | private async prepareHeaders( 105 | additionalHeaders: Record = {} 106 | ): Promise> { 107 | const headers: Record = { 108 | "Content-Type": "application/json", 109 | ...additionalHeaders, 110 | }; 111 | 112 | // Only add auth token for production environment 113 | if (!this.config.useEmulator) { 114 | const token = await this.getToken(); 115 | headers["Authorization"] = `Bearer ${token}`; 116 | } else if (this.debug) { 117 | console.log("Using emulator mode, skipping authorization header"); 118 | } 119 | 120 | return headers; 121 | } 122 | 123 | /** 124 | * Get collection reference 125 | * @param path Collection path 126 | * @returns CollectionReference instance 127 | */ 128 | collection(path: string): CollectionReference { 129 | // Configuration check is performed at the time of actual operation 130 | return new CollectionReference(this, path); 131 | } 132 | 133 | /** 134 | * Get document reference 135 | * @param path Document path 136 | * @returns DocumentReference instance 137 | */ 138 | doc(path: string): DocumentReference { 139 | // Configuration check is performed at the time of actual operation 140 | const parts = path.split("/"); 141 | if (parts.length % 2 !== 0) { 142 | throw new Error( 143 | "Invalid document path. Document path must point to a document, not a collection." 144 | ); 145 | } 146 | 147 | const collectionPath = parts.slice(0, parts.length - 1).join("/"); 148 | const docId = parts[parts.length - 1]; 149 | 150 | return new DocumentReference(this, collectionPath, docId); 151 | } 152 | 153 | /** 154 | * Get collection group reference 155 | * @param path Collection group ID 156 | * @returns CollectionGroup instance 157 | */ 158 | collectionGroup(path: string): CollectionGroup { 159 | return new CollectionGroup(this, path); 160 | } 161 | 162 | /** 163 | * Add document to Firestore 164 | * @param collectionName Collection name 165 | * @param data Data to add 166 | * @returns Added document 167 | */ 168 | async add(collectionName: string, data: Record) { 169 | // Check settings before operation 170 | this.checkConfig(); 171 | 172 | if (this.debug) { 173 | console.log(`Adding document to collection: ${collectionName}`, data); 174 | } 175 | 176 | const url = this.pathUtil.getCollectionPath(collectionName); 177 | const firestoreData = convertToFirestoreDocument(data); 178 | 179 | if (this.debug) { 180 | console.log(`Making request to: ${url}`, firestoreData); 181 | } 182 | 183 | const headers = await this.prepareHeaders(); 184 | 185 | const response = await fetch(url, { 186 | method: "POST", 187 | headers, 188 | body: JSON.stringify(firestoreData), 189 | }); 190 | 191 | if (this.debug) { 192 | console.log(`Response status: ${response.status}`); 193 | } 194 | 195 | if (!response.ok) { 196 | const errorText = await response.text(); 197 | if (this.debug) { 198 | console.error(`Error response: ${errorText}`); 199 | } 200 | throw new Error( 201 | `Firestore API error: ${ 202 | response.statusText || response.status 203 | } - ${errorText}` 204 | ); 205 | } 206 | 207 | const result = (await response.json()) as FirestoreResponse; 208 | return convertFromFirestoreDocument(result); 209 | } 210 | 211 | /** 212 | * Get document 213 | * @param collectionName Collection name 214 | * @param documentId Document ID 215 | * @returns Retrieved document (null if it doesn't exist) 216 | */ 217 | async get(collectionName: string, documentId: string) { 218 | // Check settings before operation 219 | this.checkConfig(); 220 | 221 | if (this.debug) { 222 | console.log( 223 | `Getting document from collection: ${collectionName}, documentId: ${documentId}` 224 | ); 225 | } 226 | 227 | const url = this.pathUtil.getDocumentPath(collectionName, documentId); 228 | 229 | if (this.debug) { 230 | console.log(`Making request to: ${url}`); 231 | } 232 | 233 | const headers = await this.prepareHeaders(); 234 | 235 | try { 236 | const response = await fetch(url, { 237 | method: "GET", 238 | headers, 239 | }); 240 | 241 | if (this.debug) { 242 | console.log(`Response status: ${response.status}`); 243 | } 244 | 245 | // Capture response text for debugging 246 | const responseText = await response.text(); 247 | if (this.debug) { 248 | console.log( 249 | `Response text: ${responseText.substring(0, 200)}${ 250 | responseText.length > 200 ? "..." : "" 251 | }` 252 | ); 253 | } 254 | 255 | if (response.status === 404) { 256 | return null; 257 | } 258 | 259 | if (!response.ok) { 260 | throw new Error( 261 | `Firestore API error: ${ 262 | response.statusText || response.status 263 | } - ${responseText}` 264 | ); 265 | } 266 | 267 | // Parse the response text 268 | const result = JSON.parse(responseText) as FirestoreResponse; 269 | return convertFromFirestoreDocument(result); 270 | } catch (error) { 271 | console.error("Error in get method:", error); 272 | throw error; 273 | } 274 | } 275 | 276 | /** 277 | * Update document 278 | * @param collectionName Collection name 279 | * @param documentId Document ID 280 | * @param data Data to update 281 | * @returns Updated document 282 | */ 283 | async update( 284 | collectionName: string, 285 | documentId: string, 286 | data: Record 287 | ) { 288 | // Check settings before operation 289 | this.checkConfig(); 290 | 291 | if (this.debug) { 292 | console.log( 293 | `Updating document in collection: ${collectionName}, documentId: ${documentId}`, 294 | data 295 | ); 296 | } 297 | 298 | const url = this.pathUtil.getDocumentPath(collectionName, documentId); 299 | 300 | if (this.debug) { 301 | console.log(`Making request to: ${url}`); 302 | } 303 | 304 | // Get existing document and merge 305 | const existingDoc = await this.get(collectionName, documentId); 306 | if (existingDoc) { 307 | // Check for nested fields 308 | // Check if data contains dot notation keys (e.g., "favorites.color") 309 | const updateData = { ...data }; 310 | const dotNotationKeys = Object.keys(data).filter(key => 311 | key.includes(".") 312 | ); 313 | 314 | if (dotNotationKeys.length > 0) { 315 | // スプレッド演算子でコピーして元のオブジェクトを変更しないようにする 316 | const result = { ...existingDoc }; 317 | 318 | // 通常のキーを先に適用 319 | Object.keys(data) 320 | .filter(key => !key.includes(".")) 321 | .forEach(key => { 322 | result[key] = data[key]; 323 | }); 324 | 325 | // ドット記法のキーを処理 326 | dotNotationKeys.forEach(path => { 327 | const parts = path.split("."); 328 | let current = result; 329 | 330 | // 最後のパーツ以外をたどってネストしたオブジェクトに到達 331 | for (let i = 0; i < parts.length - 1; i++) { 332 | const part = parts[i]; 333 | // パスが存在しない場合は新しいオブジェクトを作成 334 | if (!current[part] || typeof current[part] !== "object") { 335 | current[part] = {}; 336 | } 337 | current = current[part]; 338 | } 339 | 340 | // 最後のパーツに値を設定 341 | const lastPart = parts[parts.length - 1]; 342 | current[lastPart] = data[path]; 343 | 344 | // 元のデータからドット記法のキーを削除 345 | delete updateData[path]; 346 | }); 347 | 348 | data = result; 349 | } else { 350 | // 通常のマージ 351 | data = { ...existingDoc, ...data }; 352 | } 353 | } 354 | 355 | const firestoreData = convertToFirestoreDocument(data); 356 | 357 | const headers = await this.prepareHeaders(); 358 | const response = await fetch(url, { 359 | method: "PATCH", 360 | headers, 361 | body: JSON.stringify(firestoreData), 362 | }); 363 | 364 | if (this.debug) { 365 | console.log(`Response status: ${response.status}`); 366 | } 367 | 368 | if (!response.ok) { 369 | const errorText = await response.text(); 370 | if (this.debug) { 371 | console.error(`Error response: ${errorText}`); 372 | } 373 | throw new Error( 374 | `Firestore API error: ${ 375 | response.statusText || response.status 376 | } - ${errorText}` 377 | ); 378 | } 379 | 380 | const result = (await response.json()) as FirestoreResponse; 381 | return convertFromFirestoreDocument(result); 382 | } 383 | 384 | /** 385 | * Delete document 386 | * @param collectionName Collection name 387 | * @param documentId Document ID 388 | * @returns true if deletion successful 389 | */ 390 | async delete(collectionName: string, documentId: string) { 391 | // Check settings before operation 392 | this.checkConfig(); 393 | 394 | if (this.debug) { 395 | console.log( 396 | `Deleting document from collection: ${collectionName}, documentId: ${documentId}` 397 | ); 398 | } 399 | 400 | const url = this.pathUtil.getDocumentPath(collectionName, documentId); 401 | 402 | if (this.debug) { 403 | console.log(`Making request to: ${url}`); 404 | } 405 | 406 | // Different header handling for emulator 407 | const headers: Record = {}; 408 | 409 | // Only add auth token for production environment 410 | if (!this.config.useEmulator) { 411 | const token = await this.getToken(); 412 | headers["Authorization"] = `Bearer ${token}`; 413 | } 414 | 415 | const response = await fetch(url, { 416 | method: "DELETE", 417 | headers, 418 | }); 419 | 420 | if (this.debug) { 421 | console.log(`Response status: ${response.status}`); 422 | } 423 | 424 | if (!response.ok) { 425 | const errorText = await response.text(); 426 | if (this.debug) { 427 | console.error(`Error response: ${errorText}`); 428 | } 429 | throw new Error( 430 | `Firestore API error: ${ 431 | response.statusText || response.status 432 | } - ${errorText}` 433 | ); 434 | } 435 | 436 | return true; 437 | } 438 | 439 | /** 440 | * Query documents in a collection 441 | * @param collectionPath Collection path 442 | * @param options Query options 443 | * @param allDescendants Whether to include descendant collections 444 | * @returns Array of documents matching the query 445 | */ 446 | async query( 447 | collectionPath: string, 448 | options: QueryOptions = {}, 449 | allDescendants: boolean = false 450 | ) { 451 | // Check settings before operation 452 | this.checkConfig(); 453 | 454 | try { 455 | // Parse the collection path 456 | const segments = collectionPath.split("/"); 457 | const collectionId = segments[segments.length - 1]; 458 | 459 | // Get the proper runQuery URL from our path helper 460 | const queryUrl = this.pathUtil.getRunQueryPath(collectionPath); 461 | 462 | if (this.debug) { 463 | console.log(`Executing query on collection: ${collectionPath}`); 464 | console.log(`Using runQuery URL: ${queryUrl}`); 465 | } 466 | 467 | // Create the structured query 468 | const requestBody: any = { 469 | structuredQuery: { 470 | from: [ 471 | { 472 | collectionId, 473 | allDescendants, 474 | }, 475 | ], 476 | }, 477 | }; 478 | 479 | // Add where filters if present 480 | if (options.where && options.where.length > 0) { 481 | // Map our operators to Firestore REST API operators 482 | const opMap: Record = { 483 | "==": "EQUAL", 484 | "!=": "NOT_EQUAL", 485 | "<": "LESS_THAN", 486 | "<=": "LESS_THAN_OR_EQUAL", 487 | ">": "GREATER_THAN", 488 | ">=": "GREATER_THAN_OR_EQUAL", 489 | "array-contains": "ARRAY_CONTAINS", 490 | in: "IN", 491 | "array-contains-any": "ARRAY_CONTAINS_ANY", 492 | "not-in": "NOT_IN", 493 | }; 494 | 495 | // Single where clause 496 | if (options.where.length === 1) { 497 | const filter = options.where[0]; 498 | const firestoreOp = opMap[filter.op] || filter.op; 499 | 500 | requestBody.structuredQuery.where = { 501 | fieldFilter: { 502 | field: { fieldPath: filter.field }, 503 | op: firestoreOp, 504 | value: convertToFirestoreValue(filter.value), 505 | }, 506 | }; 507 | } 508 | // Multiple where clauses (AND) 509 | else { 510 | requestBody.structuredQuery.where = { 511 | compositeFilter: { 512 | op: "AND", 513 | filters: options.where.map(filter => { 514 | const firestoreOp = opMap[filter.op] || filter.op; 515 | return { 516 | fieldFilter: { 517 | field: { fieldPath: filter.field }, 518 | op: firestoreOp, 519 | value: convertToFirestoreValue(filter.value), 520 | }, 521 | }; 522 | }), 523 | }, 524 | }; 525 | } 526 | } 527 | 528 | // Add order by if present 529 | if (options.orderBy) { 530 | requestBody.structuredQuery.orderBy = [ 531 | { 532 | field: { fieldPath: options.orderBy }, 533 | direction: options.orderDirection || "ASCENDING", 534 | }, 535 | ]; 536 | } 537 | 538 | // Add limit if present 539 | if (options.limit) { 540 | requestBody.structuredQuery.limit = options.limit; 541 | } 542 | 543 | // Add offset if present 544 | if (options.offset) { 545 | requestBody.structuredQuery.offset = options.offset; 546 | } 547 | 548 | if (this.debug) { 549 | console.log(`Request payload:`, JSON.stringify(requestBody, null, 2)); 550 | } 551 | 552 | // Use the existing prepareHeaders method for authentication consistency 553 | const headers = await this.prepareHeaders(); 554 | 555 | const response = await fetch(queryUrl, { 556 | method: "POST", 557 | headers, 558 | body: JSON.stringify(requestBody), 559 | }); 560 | 561 | // Collect response for debugging 562 | const responseText = await response.text(); 563 | 564 | if (this.debug) { 565 | console.log(`API Response:`, responseText); 566 | } 567 | 568 | if (!response.ok) { 569 | throw new Error( 570 | `Firestore API error: ${response.status} - ${responseText}` 571 | ); 572 | } 573 | 574 | // Parse the response 575 | const results = JSON.parse(responseText); 576 | 577 | if (this.debug) { 578 | console.log(`Results count: ${results?.length || 0}`); 579 | } 580 | 581 | // Process the results 582 | if (!Array.isArray(results)) { 583 | return []; 584 | } 585 | 586 | const convertedResults = results 587 | .filter(item => item.document) 588 | .map(item => convertFromFirestoreDocument(item.document)); 589 | 590 | if (this.debug) { 591 | console.log(`Converted results:`, convertedResults); 592 | } 593 | 594 | return convertedResults; 595 | } catch (error) { 596 | console.error("Query execution error:", error); 597 | throw error; 598 | } 599 | } 600 | 601 | /** 602 | * ドキュメントを作成または上書き 603 | * @param collectionName コレクション名 604 | * @param documentId ドキュメントID 605 | * @param data ドキュメントデータ 606 | * @returns 作成されたドキュメントのリファレンス 607 | */ 608 | async createWithId( 609 | collectionName: string, 610 | documentId: string, 611 | data: Record 612 | ) { 613 | // 操作前に設定をチェック 614 | this.checkConfig(); 615 | 616 | const url = `${getFirestoreBasePath( 617 | this.config.projectId, 618 | this.config.databaseId, 619 | this.config 620 | )}/${collectionName}/${documentId}`; 621 | 622 | const firestoreData = convertToFirestoreDocument(data); 623 | 624 | const token = await this.getToken(); 625 | const response = await fetch(url, { 626 | method: "PATCH", 627 | headers: { 628 | "Content-Type": "application/json", 629 | Authorization: `Bearer ${token}`, 630 | }, 631 | body: JSON.stringify(firestoreData), 632 | }); 633 | 634 | if (!response.ok) { 635 | throw new Error(`Firestore API error: ${response.statusText}`); 636 | } 637 | 638 | const result = (await response.json()) as FirestoreResponse; 639 | return convertFromFirestoreDocument(result); 640 | } 641 | } 642 | 643 | /** 644 | * Collection reference class 645 | */ 646 | export class CollectionReference { 647 | private client: FirestoreClient; 648 | private _path: string; 649 | private _queryConstraints: { 650 | where: Array<{ field: string; op: string; value: any }>; 651 | orderBy?: string; 652 | orderDirection?: string; 653 | limit?: number; 654 | offset?: number; 655 | }; 656 | 657 | constructor(client: FirestoreClient, path: string) { 658 | this.client = client; 659 | this._path = path; 660 | this._queryConstraints = { 661 | where: [], 662 | }; 663 | } 664 | 665 | /** 666 | * Get collection path 667 | */ 668 | get path(): string { 669 | return this._path; 670 | } 671 | 672 | /** 673 | * Whether to include all descendant collections 674 | */ 675 | get allDescendants(): boolean { 676 | return false; 677 | } 678 | 679 | /** 680 | * Get document reference 681 | * @param documentPath Document ID (auto-generated if omitted) 682 | * @returns DocumentReference instance 683 | */ 684 | doc(documentPath?: string): DocumentReference { 685 | const docId = documentPath || this._generateId(); 686 | return new DocumentReference(this.client, this.path, docId); 687 | } 688 | 689 | /** 690 | * Add document (ID is auto-generated) 691 | * @param data Document data 692 | * @returns Reference to the created document 693 | */ 694 | async add(data: Record): Promise { 695 | const result = await this.client.add(this.path, data); 696 | const docId = result.id; 697 | return new DocumentReference(this.client, this.path, docId); 698 | } 699 | 700 | /** 701 | * Add filter condition 702 | * @param fieldPath Field path 703 | * @param opStr Operator 704 | * @param value Value 705 | * @returns Query instance 706 | */ 707 | where(fieldPath: string, opStr: string, value: any): Query { 708 | const query = new Query( 709 | this.client, 710 | this.path, 711 | { 712 | ...this._queryConstraints, 713 | }, 714 | this.allDescendants 715 | ); 716 | 717 | // Operator conversion 718 | let firestoreOp: string; 719 | switch (opStr) { 720 | case "==": 721 | firestoreOp = "EQUAL"; 722 | break; 723 | case "!=": 724 | firestoreOp = "NOT_EQUAL"; 725 | break; 726 | case "<": 727 | firestoreOp = "LESS_THAN"; 728 | break; 729 | case "<=": 730 | firestoreOp = "LESS_THAN_OR_EQUAL"; 731 | break; 732 | case ">": 733 | firestoreOp = "GREATER_THAN"; 734 | break; 735 | case ">=": 736 | firestoreOp = "GREATER_THAN_OR_EQUAL"; 737 | break; 738 | case "array-contains": 739 | firestoreOp = "ARRAY_CONTAINS"; 740 | break; 741 | case "in": 742 | firestoreOp = "IN"; 743 | break; 744 | case "array-contains-any": 745 | firestoreOp = "ARRAY_CONTAINS_ANY"; 746 | break; 747 | case "not-in": 748 | firestoreOp = "NOT_IN"; 749 | break; 750 | default: 751 | firestoreOp = opStr; 752 | } 753 | 754 | query._queryConstraints.where.push({ 755 | field: fieldPath, 756 | op: firestoreOp, 757 | value, 758 | }); 759 | 760 | return query; 761 | } 762 | 763 | /** 764 | * Add sorting condition 765 | * @param fieldPath Field path 766 | * @param directionStr Sort direction ('asc' or 'desc') 767 | * @returns Query instance 768 | */ 769 | orderBy(fieldPath: string, directionStr: "asc" | "desc" = "asc"): Query { 770 | const query = new Query( 771 | this.client, 772 | this.path, 773 | { 774 | ...this._queryConstraints, 775 | }, 776 | this.allDescendants 777 | ); 778 | query._queryConstraints.orderBy = fieldPath; 779 | query._queryConstraints.orderDirection = 780 | directionStr === "asc" ? "ASCENDING" : "DESCENDING"; 781 | return query; 782 | } 783 | 784 | /** 785 | * Set limit on number of results 786 | * @param limit Maximum number 787 | * @returns Query instance 788 | */ 789 | limit(limit: number): Query { 790 | const query = new Query( 791 | this.client, 792 | this.path, 793 | { 794 | ...this._queryConstraints, 795 | }, 796 | this.allDescendants 797 | ); 798 | query._queryConstraints.limit = limit; 799 | return query; 800 | } 801 | 802 | /** 803 | * Set number of documents to skip 804 | * @param offset Number to skip 805 | * @returns Query instance 806 | */ 807 | offset(offset: number): Query { 808 | const query = new Query( 809 | this.client, 810 | this.path, 811 | { 812 | ...this._queryConstraints, 813 | }, 814 | this.allDescendants 815 | ); 816 | query._queryConstraints.offset = offset; 817 | return query; 818 | } 819 | 820 | /** 821 | * Execute query 822 | * @returns QuerySnapshot instance 823 | */ 824 | async get(): Promise { 825 | const results = await this.client.query( 826 | this.path, 827 | this._queryConstraints, 828 | this.allDescendants 829 | ); 830 | return new QuerySnapshot(results); 831 | } 832 | 833 | /** 834 | * Generate random ID 835 | * @returns Random ID 836 | */ 837 | private _generateId(): string { 838 | // Generate 20-character random ID 839 | const chars = 840 | "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; 841 | let id = ""; 842 | for (let i = 0; i < 20; i++) { 843 | id += chars.charAt(Math.floor(Math.random() * chars.length)); 844 | } 845 | return id; 846 | } 847 | } 848 | 849 | /** 850 | * Document reference class 851 | */ 852 | export class DocumentReference { 853 | private client: FirestoreClient; 854 | private collectionPath: string; 855 | private docId: string; 856 | 857 | constructor(client: FirestoreClient, collectionPath: string, docId: string) { 858 | this.client = client; 859 | this.collectionPath = collectionPath; 860 | this.docId = docId; 861 | } 862 | 863 | /** 864 | * Get document ID 865 | */ 866 | get id(): string { 867 | return this.docId; 868 | } 869 | 870 | /** 871 | * Get document path 872 | */ 873 | get path(): string { 874 | return `${this.collectionPath}/${this.docId}`; 875 | } 876 | 877 | /** 878 | * Get parent collection reference 879 | */ 880 | get parent(): CollectionReference { 881 | return new CollectionReference(this.client, this.collectionPath); 882 | } 883 | 884 | /** 885 | * Get subcollection 886 | * @param collectionPath Subcollection name 887 | * @returns CollectionReference instance 888 | */ 889 | collection(collectionPath: string): CollectionReference { 890 | return new CollectionReference( 891 | this.client, 892 | `${this.path}/${collectionPath}` 893 | ); 894 | } 895 | 896 | /** 897 | * Get document 898 | * @returns DocumentSnapshot instance 899 | */ 900 | async get(): Promise { 901 | const data = await this.client.get(this.collectionPath, this.docId); 902 | return new DocumentSnapshot(this.docId, data); 903 | } 904 | 905 | /** 906 | * Create or overwrite document 907 | * @param data Document data 908 | * @param options Options (merge is not currently supported) 909 | * @returns WriteResult instance 910 | */ 911 | async set( 912 | data: Record, 913 | options?: { merge?: boolean } 914 | ): Promise { 915 | // Get existing document 916 | const existingDoc = await this.client.get(this.collectionPath, this.docId); 917 | 918 | if (existingDoc) { 919 | // If existing document exists, update 920 | const mergedData = options?.merge ? { ...existingDoc, ...data } : data; 921 | await this.client.update(this.collectionPath, this.docId, mergedData); 922 | } else { 923 | // New creation 924 | await this.client.createWithId(this.collectionPath, this.docId, data); 925 | } 926 | 927 | return new WriteResult(); 928 | } 929 | 930 | /** 931 | * Update document 932 | * @param data Update data 933 | * @returns WriteResult instance 934 | */ 935 | async update(data: Record): Promise { 936 | await this.client.update(this.collectionPath, this.docId, data); 937 | return new WriteResult(); 938 | } 939 | 940 | /** 941 | * Delete document 942 | * @returns WriteResult instance 943 | */ 944 | async delete(): Promise { 945 | await this.client.delete(this.collectionPath, this.docId); 946 | return new WriteResult(); 947 | } 948 | } 949 | 950 | /** 951 | * Collection group 952 | */ 953 | export class CollectionGroup { 954 | private client: FirestoreClient; 955 | private path: string; 956 | private _queryConstraints: { 957 | where: Array<{ field: string; op: string; value: any }>; 958 | orderBy?: string; 959 | orderDirection?: string; 960 | limit?: number; 961 | offset?: number; 962 | }; 963 | 964 | constructor(client: FirestoreClient, path: string) { 965 | this.client = client; 966 | this.path = path; 967 | this._queryConstraints = { 968 | where: [], 969 | }; 970 | } 971 | 972 | /** 973 | * Whether to include all descendant collections 974 | */ 975 | get allDescendants(): boolean { 976 | return true; 977 | } 978 | 979 | /** 980 | * Add filter condition 981 | * @param fieldPath Field path 982 | * @param opStr Operator 983 | * @param value Value 984 | * @returns Query instance 985 | */ 986 | where(fieldPath: string, opStr: string, value: any): Query { 987 | const query = new Query( 988 | this.client, 989 | this.path, 990 | { 991 | ...this._queryConstraints, 992 | }, 993 | this.allDescendants 994 | ); 995 | 996 | // Operator conversion 997 | let firestoreOp: string; 998 | switch (opStr) { 999 | case "==": 1000 | firestoreOp = "EQUAL"; 1001 | break; 1002 | case "!=": 1003 | firestoreOp = "NOT_EQUAL"; 1004 | break; 1005 | case "<": 1006 | firestoreOp = "LESS_THAN"; 1007 | break; 1008 | case "<=": 1009 | firestoreOp = "LESS_THAN_OR_EQUAL"; 1010 | break; 1011 | case ">": 1012 | firestoreOp = "GREATER_THAN"; 1013 | break; 1014 | case ">=": 1015 | firestoreOp = "GREATER_THAN_OR_EQUAL"; 1016 | break; 1017 | case "array-contains": 1018 | firestoreOp = "ARRAY_CONTAINS"; 1019 | break; 1020 | case "in": 1021 | firestoreOp = "IN"; 1022 | break; 1023 | case "array-contains-any": 1024 | firestoreOp = "ARRAY_CONTAINS_ANY"; 1025 | break; 1026 | case "not-in": 1027 | firestoreOp = "NOT_IN"; 1028 | break; 1029 | default: 1030 | firestoreOp = opStr; 1031 | } 1032 | 1033 | query._queryConstraints.where.push({ 1034 | field: fieldPath, 1035 | op: firestoreOp, 1036 | value, 1037 | }); 1038 | 1039 | return query; 1040 | } 1041 | 1042 | /** 1043 | * Add sorting condition 1044 | * @param fieldPath Field path 1045 | * @param directionStr Sort direction ('asc' or 'desc') 1046 | * @returns Query instance 1047 | */ 1048 | orderBy(fieldPath: string, directionStr: "asc" | "desc" = "asc"): Query { 1049 | const query = new Query( 1050 | this.client, 1051 | this.path, 1052 | { 1053 | ...this._queryConstraints, 1054 | }, 1055 | this.allDescendants 1056 | ); 1057 | query._queryConstraints.orderBy = fieldPath; 1058 | query._queryConstraints.orderDirection = 1059 | directionStr === "asc" ? "ASCENDING" : "DESCENDING"; 1060 | return query; 1061 | } 1062 | 1063 | /** 1064 | * Set limit on number of results 1065 | * @param limit Maximum number 1066 | * @returns Query instance 1067 | */ 1068 | limit(limit: number): Query { 1069 | const query = new Query( 1070 | this.client, 1071 | this.path, 1072 | { 1073 | ...this._queryConstraints, 1074 | }, 1075 | this.allDescendants 1076 | ); 1077 | query._queryConstraints.limit = limit; 1078 | return query; 1079 | } 1080 | 1081 | /** 1082 | * Set number of documents to skip 1083 | * @param offset Number to skip 1084 | * @returns Query instance 1085 | */ 1086 | offset(offset: number): Query { 1087 | const query = new Query( 1088 | this.client, 1089 | this.path, 1090 | { 1091 | ...this._queryConstraints, 1092 | }, 1093 | this.allDescendants 1094 | ); 1095 | query._queryConstraints.offset = offset; 1096 | return query; 1097 | } 1098 | 1099 | /** 1100 | * Execute query 1101 | * @returns QuerySnapshot instance 1102 | */ 1103 | async get(): Promise { 1104 | const results = await this.client.query( 1105 | this.path, 1106 | this._queryConstraints, 1107 | this.allDescendants 1108 | ); 1109 | return new QuerySnapshot(results); 1110 | } 1111 | } 1112 | 1113 | /** 1114 | * Query class 1115 | */ 1116 | export class Query { 1117 | private client: FirestoreClient; 1118 | private collectionPath: string; 1119 | private allDescendants: boolean; 1120 | _queryConstraints: { 1121 | where: Array<{ field: string; op: string; value: any }>; 1122 | orderBy?: string; 1123 | orderDirection?: string; 1124 | limit?: number; 1125 | offset?: number; 1126 | }; 1127 | 1128 | constructor( 1129 | client: FirestoreClient, 1130 | collectionPath: string, 1131 | constraints: { 1132 | where: Array<{ field: string; op: string; value: any }>; 1133 | orderBy?: string; 1134 | orderDirection?: string; 1135 | limit?: number; 1136 | offset?: number; 1137 | }, 1138 | allDescendants: boolean 1139 | ) { 1140 | this.client = client; 1141 | this.collectionPath = collectionPath; 1142 | this._queryConstraints = constraints; 1143 | this.allDescendants = allDescendants; 1144 | } 1145 | 1146 | /** 1147 | * Add filter condition 1148 | * @param fieldPath Field path 1149 | * @param opStr Operator 1150 | * @param value Value 1151 | * @returns Query instance 1152 | */ 1153 | where(fieldPath: string, opStr: string, value: any): Query { 1154 | const query = new Query( 1155 | this.client, 1156 | this.collectionPath, 1157 | { 1158 | ...this._queryConstraints, 1159 | }, 1160 | this.allDescendants 1161 | ); 1162 | 1163 | // Operator conversion 1164 | let firestoreOp: string; 1165 | switch (opStr) { 1166 | case "==": 1167 | firestoreOp = "EQUAL"; 1168 | break; 1169 | case "!=": 1170 | firestoreOp = "NOT_EQUAL"; 1171 | break; 1172 | case "<": 1173 | firestoreOp = "LESS_THAN"; 1174 | break; 1175 | case "<=": 1176 | firestoreOp = "LESS_THAN_OR_EQUAL"; 1177 | break; 1178 | case ">": 1179 | firestoreOp = "GREATER_THAN"; 1180 | break; 1181 | case ">=": 1182 | firestoreOp = "GREATER_THAN_OR_EQUAL"; 1183 | break; 1184 | case "array-contains": 1185 | firestoreOp = "ARRAY_CONTAINS"; 1186 | break; 1187 | case "in": 1188 | firestoreOp = "IN"; 1189 | break; 1190 | case "array-contains-any": 1191 | firestoreOp = "ARRAY_CONTAINS_ANY"; 1192 | break; 1193 | case "not-in": 1194 | firestoreOp = "NOT_IN"; 1195 | break; 1196 | default: 1197 | firestoreOp = opStr; 1198 | } 1199 | 1200 | query._queryConstraints.where.push({ 1201 | field: fieldPath, 1202 | op: firestoreOp, 1203 | value, 1204 | }); 1205 | 1206 | return query; 1207 | } 1208 | 1209 | /** 1210 | * Add sorting condition 1211 | * @param fieldPath Field path 1212 | * @param directionStr Sort direction ('asc' or 'desc') 1213 | * @returns Query instance 1214 | */ 1215 | orderBy(fieldPath: string, directionStr: "asc" | "desc" = "asc"): Query { 1216 | const query = new Query( 1217 | this.client, 1218 | this.collectionPath, 1219 | { 1220 | ...this._queryConstraints, 1221 | }, 1222 | this.allDescendants 1223 | ); 1224 | query._queryConstraints.orderBy = fieldPath; 1225 | query._queryConstraints.orderDirection = 1226 | directionStr === "asc" ? "ASCENDING" : "DESCENDING"; 1227 | return query; 1228 | } 1229 | 1230 | /** 1231 | * Set limit on number of results 1232 | * @param limit Maximum number 1233 | * @returns Query instance 1234 | */ 1235 | limit(limit: number): Query { 1236 | const query = new Query( 1237 | this.client, 1238 | this.collectionPath, 1239 | { 1240 | ...this._queryConstraints, 1241 | }, 1242 | this.allDescendants 1243 | ); 1244 | query._queryConstraints.limit = limit; 1245 | return query; 1246 | } 1247 | 1248 | /** 1249 | * Set number of documents to skip 1250 | * @param offset Number to skip 1251 | * @returns Query instance 1252 | */ 1253 | offset(offset: number): Query { 1254 | const query = new Query( 1255 | this.client, 1256 | this.collectionPath, 1257 | { 1258 | ...this._queryConstraints, 1259 | }, 1260 | this.allDescendants 1261 | ); 1262 | query._queryConstraints.offset = offset; 1263 | return query; 1264 | } 1265 | 1266 | /** 1267 | * Execute query 1268 | * @returns QuerySnapshot instance 1269 | */ 1270 | async get(): Promise { 1271 | const results = await this.client.query( 1272 | this.collectionPath, 1273 | this._queryConstraints, 1274 | this.allDescendants 1275 | ); 1276 | return new QuerySnapshot(results); 1277 | } 1278 | } 1279 | 1280 | /** 1281 | * Query result class 1282 | */ 1283 | export class QuerySnapshot { 1284 | private _docs: DocumentSnapshot[]; 1285 | 1286 | constructor(results: Array>) { 1287 | this._docs = results.map(doc => { 1288 | const { id, ...data } = doc; 1289 | return new DocumentSnapshot(id, data); 1290 | }); 1291 | } 1292 | 1293 | /** 1294 | * Array of documents in the result 1295 | */ 1296 | get docs(): DocumentSnapshot[] { 1297 | return this._docs; 1298 | } 1299 | 1300 | /** 1301 | * Whether the result is empty 1302 | */ 1303 | get empty(): boolean { 1304 | return this._docs.length === 0; 1305 | } 1306 | 1307 | /** 1308 | * Number of results 1309 | */ 1310 | get size(): number { 1311 | return this._docs.length; 1312 | } 1313 | 1314 | /** 1315 | * Execute callback for each document 1316 | * @param callback Callback function to execute for each document 1317 | */ 1318 | forEach(callback: (result: DocumentSnapshot) => void): void { 1319 | this._docs.forEach(callback); 1320 | } 1321 | } 1322 | 1323 | /** 1324 | * Document snapshot class 1325 | */ 1326 | export class DocumentSnapshot { 1327 | private _id: string; 1328 | private _data: Record | null; 1329 | 1330 | constructor(id: string, data: Record | null) { 1331 | this._id = id; 1332 | this._data = data; 1333 | } 1334 | 1335 | /** 1336 | * Document ID 1337 | */ 1338 | get id(): string { 1339 | return this._id; 1340 | } 1341 | 1342 | /** 1343 | * Whether the document exists 1344 | */ 1345 | get exists(): boolean { 1346 | return this._data !== null; 1347 | } 1348 | 1349 | /** 1350 | * Get document data 1351 | * @returns Document data (undefined if it doesn't exist) 1352 | */ 1353 | data(): Record | undefined { 1354 | return this._data || undefined; 1355 | } 1356 | } 1357 | 1358 | /** 1359 | * Write result class 1360 | */ 1361 | export class WriteResult { 1362 | /** 1363 | * Write timestamp 1364 | */ 1365 | readonly writeTime: Date; 1366 | 1367 | constructor() { 1368 | this.writeTime = new Date(); 1369 | } 1370 | } 1371 | 1372 | /** 1373 | * Create a new Firestore client instance 1374 | * @param config Firestore configuration object 1375 | * @returns FirestoreClient instance 1376 | * 1377 | * @example 1378 | * // Connect to default database 1379 | * const db = createFirestoreClient({ 1380 | * projectId: 'your-project-id', 1381 | * privateKey: 'your-private-key', 1382 | * clientEmail: 'your-client-email' 1383 | * }); 1384 | * 1385 | * // Connect to a different named database 1386 | * const customDb = createFirestoreClient({ 1387 | * projectId: 'your-project-id', 1388 | * privateKey: 'your-private-key', 1389 | * clientEmail: 'your-client-email', 1390 | * databaseId: 'your-database-id' 1391 | * }); 1392 | * 1393 | * // Connect to local emulator (no auth required) 1394 | * const emulatorDb = createFirestoreClient({ 1395 | * projectId: 'demo-project', 1396 | * useEmulator: true, 1397 | * emulatorHost: '127.0.', 1398 | * emulatorPort: 8080, 1399 | * debug: true // Optional: enables detailed logging 1400 | * }); 1401 | */ 1402 | export function createFirestoreClient(config: FirestoreConfig) { 1403 | // Check private key format 1404 | if (config.privateKey) { 1405 | config = { 1406 | ...config, 1407 | privateKey: formatPrivateKey(config.privateKey), 1408 | }; 1409 | } 1410 | return new FirestoreClient(config); 1411 | } 1412 | --------------------------------------------------------------------------------