zod-firebase-admin
Version:
zod firebase-admin schema
516 lines (404 loc) • 15.2 kB
Markdown
# zod-firebase-admin
[](https://badge.fury.io/js/zod-firebase-admin)
[](https://opensource.org/licenses/MIT)
[](https://github.com/valian-ca/zod-firebase-admin/actions/workflows/pull-request.yml)
[](https://codecov.io/gh/valian-ca/zod-firebase-admin)
Type-safe Firestore collections and documents using [Zod](https://zod.dev/) schemas for the [Firebase Admin SDK](https://github.com/firebase/firebase-admin-node).
## Installation
Peer dependencies: `firebase-admin` and `zod`.
```bash
npm install zod-firebase-admin zod firebase-admin
```
Node.js >= 22 is recommended (matches the library engines field). ESM and CJS bundles are provided.
## Usage
### Basic Setup
First, define your document schemas using Zod:
```typescript
import { z } from 'zod'
import { collectionsBuilder } from 'zod-firebase-admin'
// Define your document schemas
const UserSchema = z.object({
name: z.string(),
email: z.string().email(),
age: z.number().optional(),
tags: z.array(z.string()).optional().default([]),
})
const PostSchema = z.object({
title: z.string(),
content: z.string(),
authorId: z.string(),
publishedAt: z.date(),
likes: z.number().default(0),
})
// Define your collection schema
const schema = {
users: {
zod: UserSchema,
},
posts: {
zod: PostSchema,
},
} as const
// Build type-safe collections
const collections = collectionsBuilder(schema)
```
### CRUD Operations
```typescript
// Create a new user
const userRef = await collections.users.add({
name: 'John Doe',
email: 'john@example.com',
age: 30,
})
// Get a user by ID
const user = await collections.users.findByIdOrThrow(userRef.id)
console.log(user._id, user.name, user.email) // Fully typed!
// Update a user
await collections.users.update(userRef.id, {
age: 31,
})
// Query users
const adults = await collections.users.findMany({
name: 'adults',
where: [['age', '>=', 18]],
})
// Delete a user
await collections.users.delete(userRef.id)
```
### Fallback reads
Return a document if it exists or a validated fallback when it does not.
```typescript
// Multi-document collection: findByIdWithFallback(id, fallback)
const post = await collections.posts.findByIdWithFallback('post123', {
title: 'Untitled',
content: '',
authorId: 'anonymous',
publishedAt: new Date(),
likes: 0,
})
// If the document exists, you get its data; otherwise you get:
// { _id: 'post123', title: 'Untitled', content: '', ... }
// Single-document collection: findWithFallback(fallback)
const userId = 'user123'
const profile = await collections.users(userId).profile.findWithFallback({
bio: 'This user has not set up a bio yet',
avatar: undefined,
})
// If the document does not exist, you get { _id: 'profile', ...fallback }
```
When your schema validates document IDs (includeDocumentIdForZod), the fallback is validated
with the injected `_id`:
```typescript
const UserWithIdSchema = z.discriminatedUnion('_id', [
z.object({
_id: z.literal('admin'),
name: z.string(),
role: z.literal('administrator'),
}),
z.object({
_id: z.string(),
name: z.string(),
role: z.literal('user'),
}),
])
const schema = {
users: {
zod: UserWithIdSchema,
includeDocumentIdForZod: true,
},
} as const
const collections = collectionsBuilder(schema)
// Fallback excludes _id; it will be injected and validated by Zod
const admin = await collections.users.findByIdWithFallback('admin', {
name: 'System',
role: 'administrator',
})
```
### Sub-Collections
You can define nested sub-collections with full type safety:
```typescript
const schema = {
users: {
zod: UserSchema,
posts: {
zod: PostSchema,
// Sub-sub-collections
comments: {
zod: z.object({
text: z.string(),
authorId: z.string(),
createdAt: z.date(),
}),
},
},
// Single document sub-collection
profile: {
zod: z.object({
bio: z.string(),
avatar: z.string().optional(),
}),
singleDocumentKey: 'profile', // Fixed document ID
},
},
} as const
const collections = collectionsBuilder(schema)
// Working with sub-collections
const userId = 'user123'
// Add a post to user's posts sub-collection
const postRef = await collections.users(userId).posts.add({
title: 'My First Post',
content: 'Hello world!',
authorId: userId,
publishedAt: new Date(),
})
// Add a comment to the post
await collections.users(userId).posts(postRef.id).comments.add({
text: 'Great post!',
authorId: 'commenter123',
createdAt: new Date(),
})
// Work with single document sub-collection
await collections.users(userId).profile.set({
bio: 'Software developer',
avatar: 'https://example.com/avatar.jpg',
})
const profile = await collections.users(userId).profile.findOrThrow()
```
### Collection Group Queries
Query across all sub-collections of the same type:
```typescript
// Count all posts across all users
const postCount = await collections.users.posts.group.count({
name: 'all-posts',
})
// Count all comments across all posts
const commentCount = await collections.users.posts.comments.group.count({
name: 'all-comments',
})
```
### Advanced Schema Options
#### Custom Error Handling
```typescript
const collections = collectionsBuilder(schema, {
zodErrorHandler: (error, snapshot) => {
console.error(`Validation error for document ${snapshot.id}:`, error)
return new Error(`Invalid document: ${snapshot.id}`)
},
})
```
#### Data Transformation
Handle Firebase-specific data types like Timestamps:
```typescript
import { Timestamp } from 'firebase-admin/firestore'
const collections = collectionsBuilder(schema, {
snapshotDataConverter: (snapshot) => {
const data = snapshot.data()
// Convert Firestore Timestamps to JavaScript Dates
return Object.fromEntries(
Object.entries(data).map(([key, value]) => [key, value instanceof Timestamp ? value.toDate() : value]),
)
},
})
```
#### Document ID Validation
Include document IDs in Zod validation:
```typescript
const UserWithIdSchema = z.discriminatedUnion('_id', [
z.object({
_id: z.literal('admin'),
name: z.string(),
role: z.literal('administrator'),
}),
z.object({
_id: z.string(),
name: z.string(),
role: z.literal('user'),
}),
])
const schema = {
users: {
zod: UserWithIdSchema,
includeDocumentIdForZod: true,
},
} as const
```
#### Read-Only Documents
Mark collections as read-only to prevent accidental modifications:
```typescript
const schema = {
config: {
zod: ConfigSchema,
readonlyDocuments: true,
},
} as const
// This collection will only have read operations available
const collections = collectionsBuilder(schema)
```
### Transactions and Batches
Work with Firebase Admin transactions and batch writes:
```typescript
import { getFirestore } from 'firebase-admin/firestore'
const db = getFirestore()
// Using transactions
await db.runTransaction(async (transaction) => {
const userSnap = await transaction.get(collections.users.read.doc('user123'))
if (!userSnap.exists) return
transaction.update(collections.users.write.doc('user123'), { age: FieldValue.increment(1) })
})
// Using batch writes
const batch = db.batch()
batch.create(collections.posts.write.doc('post123'), {
title: 'Batch Post',
authorId: 'user123',
})
await batch.commit()
```
### Firebase Admin SDK Features
Take advantage of Firebase Admin SDK server-side capabilities:
```typescript
// Precondition checks with last update time
const user = await collections.users.findByIdOrThrow('user123', {
_updateTime: true,
})
await collections.users.update(
'user123',
{
name: 'Updated Name',
},
{
lastUpdateTime: user._updateTime, // Prevents concurrent modifications
},
)
// Server timestamps and field values
import { FieldValue } from 'firebase-admin/firestore'
await collections.users.update('user123', {
lastLoginAt: FieldValue.serverTimestamp(),
loginCount: FieldValue.increment(1),
})
```
### Query Features
#### Advanced Queries
```typescript
// Complex queries with multiple conditions
const recentPopularPosts = await collections.posts.findMany({
where: [
['publishedAt', '>=', new Date('2024-01-01')],
['likes', '>=', 100],
],
orderBy: [['likes', 'desc']],
limit: 10,
})
// Prepared queries for reuse
const popularPrepared = collections.posts.prepare({
name: 'popular',
where: [['likes', '>=', 100]],
orderBy: [['likes', 'desc']],
})
const snapshot = await popularPrepared.get()
const results = snapshot.docs.map((d) => d.data())
```
#### QuerySpecification
The `QuerySpecification` accepted by `prepare`, `query`, `find*`, and `count` supports:
- **name**: A label for your query, used in error messages
- **where?**: Either an array of tuples `[field, op, value]` or an Admin `Filter`
- Tuples: [Simple queries](https://firebase.google.com/docs/firestore/query-data/queries)
- Filters: [Admin Filter API](https://cloud.google.com/nodejs/docs/reference/firestore/latest/Filter)
- **orderBy?**: Array of tuples `[field]` or `[field, 'asc' | 'desc']`
- Docs: [Order and limit data](https://firebase.google.com/docs/firestore/query-data/order-limit-data)
- **limit?**: Maximum number of results
- Docs: [Order and limit data](https://firebase.google.com/docs/firestore/query-data/order-limit-data)
- **limitToLast?**: Returns the last N results; requires a matching `orderBy`
- Docs: [Order and limit data](https://firebase.google.com/docs/firestore/query-data/order-limit-data)
- **offset?**: Skip the first N results
- Docs: [Pagination with offset](https://cloud.google.com/nodejs/docs/reference/firestore/latest/Query#offset)
- **startAt?** | **startAfter?** | **endAt?** | **endBefore?**: Cursor boundaries, each can be a document snapshot or an array of field values
- Arrays are forwarded as individual arguments (e.g. `startAt(...values)`). Ensure the order matches your `orderBy`
- Docs: [Query cursors](https://firebase.google.com/docs/firestore/query-data/query-cursors)
Related:
- Collection group queries: [Guide](https://firebase.google.com/docs/firestore/query-data/queries#collection-group-query)
- Aggregations via Admin SDK `count()`: [Admin Query.count](https://cloud.google.com/nodejs/docs/reference/firestore/latest/Query#count)
#### Metadata Access
Access Firestore metadata when needed:
```typescript
// Get document with metadata
const userWithMeta = await collections.users.findByIdOrThrow(userId, {
_createTime: true,
_updateTime: true,
})
console.log(userWithMeta._createTime, userWithMeta._updateTime)
```
## API Reference
### Types
- `DocumentInput<Z>`: Input type inferred with `z.input<Z>` from your Zod schema `Z` (data you write). See [Zod’s input/output docs](https://zod.dev/?id=input-vs-output)
- `DocumentOutput<Z, Options>`: Output type inferred with `z.output<Z>` from your Zod schema `Z`, plus optional metadata based on `Options`:
- `_id` (string) included by default unless `{ _id: false }`
- `_createTime` / `_updateTime` available via operation options when reading, `_metadata` for web parity is not used here
- When `{ readonly: true }`, the data portion is deeply readonly
- `SchemaDocumentInput<TCollectionSchema>`: Input type for a collection built from a Zod schema; accepts either the input type or a deeply readonly version of it
- `SchemaDocumentOutput<TCollectionSchema, Options>`: Output type for a collection built from a Zod schema, mirroring `DocumentOutput` behavior and honoring `readonlyDocuments` in the collection schema
Examples:
```ts
import { z } from 'zod'
const User = z.object({
name: z.string(),
admin: z.boolean().default(false),
})
type UserInput = DocumentInput<typeof User>
type UserOutput = DocumentOutput<typeof User>
type ReadonlyUserOutput = DocumentOutput<typeof User, { readonly: true }>
// With collectionsBuilder
const schema = {
users: { zod: User },
} as const
type UsersInput = SchemaDocumentInput<typeof schema.users>
// { name: string; admin?: boolean } | ReadonlyDeep<{ name: string; admin?: boolean }>
type UsersOutput = SchemaDocumentOutput<typeof schema.users>
// { _id: string; name: string; admin: boolean }
// Include Admin metadata in outputs
type UsersOutputWithTimes = SchemaDocumentOutput<typeof schema.users, { _createTime: true; _updateTime: true }>
// { _id: string; _createTime: Timestamp; _updateTime: Timestamp; name: string; admin: boolean }
// Readonly collection
const readonlySchema = {
users: { zod: User, readonlyDocuments: true },
} as const
type ReadonlyUsersOutput = SchemaDocumentOutput<typeof readonlySchema.users>
// ReadonlyDeep<{ name: string; admin: boolean }> & { _id: string }
```
### Collection Methods
- `add(data)` - Add a new document with auto-generated ID
- `create(id, data)` - Create a document with specific ID
- `set(id, data, options?)` - Set document data (overwrites)
- `update(id, data, options?)` - Update document fields
- `delete(id, options?)` - Delete a document
- `findById(id, options?)` - Find document by ID (returns undefined if not found)
- `findByIdOrThrow(id, options?)` - Find document by ID (throws if not found)
- `findMany(query)` - Query multiple documents
- `count(query)` - Count documents matching query
- `prepare(query)` - Prepare a query for reuse
### Sub-Collection Access
- `collection(parentId).subCollection` - Access sub-collection
- `collection.subCollection.group` - Access collection group
### Configuration Options
- `zodErrorHandler` - Custom error handling for validation failures
- `snapshotDataConverter` - Transform document data before validation
- `includeDocumentIdForZod` - Include document ID in Zod validation
- `readonlyDocuments` - Mark collection as read-only
- `singleDocumentKey` - Create single-document sub-collections
### Operation Options
- `transaction` - Run operation within a transaction
- `batch` - Add operation to a batch write
- `lastUpdateTime` - Precondition check for updates
- `_createTime` / `_updateTime` - Include metadata in results
## Firebase Admin vs Web SDK
This package is designed for server-side applications using the Firebase Admin SDK. Key differences from the web SDK version (`zod-firebase`):
- **Server Environment**: Runs in Node.js with admin privileges
- **No Authentication**: Admin SDK bypasses Firebase Auth
- **Transactions**: Full transaction support with preconditions
- **Batch Operations**: Atomic batch writes
- **Server Timestamps**: Access to server-side timestamp operations
- **Metadata Access**: Read/write times and other document metadata
For client-side applications, use [`zod-firebase`](https://www.npmjs.com/package/zod-firebase) instead.
## License
MIT
## Contributing
See the main repository at [valian-ca/zod-firebase-admin](https://github.com/valian-ca/zod-firebase-admin) for contributing guidelines.