Zod Schema |
Security Rules Output |
z.any() |
true |
z.unknown() |
true |
z.undefined() |
!("key" in data) |
z.null() |
data.key == null |
z.boolean() |
data.key is bool |
z.literal('a') |
data.key == "a" |
z.string() |
data.key is string |
z.string().min(5) |
(data.key is string && data.key.size >= 5) |
z.string().min(5).max(20) |
(data.key is string && data.key.size >= 5 && data.key.size <= 20) |
z.string().regex(/@example\.com$/) |
(data.key is string && data.key.matches("@example\\.com$")) |
z.number() |
data.key is number |
z.number().int() |
data.key is int |
z.number().min(5) |
(data.key is int && data.key >= 5) |
z.number().max(20) |
(data.key is int && data.key <= 20) |
timestampType() |
data.key is timestamp |
z.record(z.string()) |
data.key is map |
z.tuple([z.string(), z.number()]) |
(data.key is list && data.key[0] is string && data.key[1] is number) |
z.string().array() |
data.key is list |
z.string().array().min(5) |
(data.key is list && data.key.size() >= 5) |
z.string().array().max(20) |
(data.key is list && data.key.size() <= 20) |
z.string().optional() |
`(data.key is string \ |
\ |
!("key" in data))` |
z.union([z.string(), z.null()]) |
`(data.key is string \ |
\ |
data.key == null)` |
1. Define schema
The schema definition must be default exported.
import { Merge } from 'type-fest'
import { z } from 'zod'
import { DataModel, FirestoreModel, rules, timestampType } from 'fireschema'
export const UserType = z.object({
name: z.string(),
displayName: z.union([z.string(), z.null()]),
age: z.number().int(),
timestamp: timestampType(),
options: z.object({ a: z.boolean() }).optional(),
})
type User = z.infer<typeof UserType>
/* => {
name: string
displayName: string | null
age: number
timestamp: FTypes.Timestamp
options?: { a: boolean } | undefined
} */
type UserDecoded = Merge<User, { timestamp: Date }>
const UserModel = new DataModel({
schema: UserType,
decoder: (data: User): UserDecoded => ({
...data,
timestamp: data.timestamp.toDate(),
}),
})
const PostType = z.object({
authorUid: z.string(),
text: z.string(),
tags: z.object({ id: z.number().int(), name: z.string() }).array(),
})
const PostModel = new DataModel({
schema: PostType,
selectors: (q) => ({
byTag: (tag: string) => [
q.where('tags', 'array-contains', tag),
q.limit(20),
],
}),
})
export const firestoreModel = new FirestoreModel({
'function isAdmin()': `
return exists(${rules.basePath}/admins/$(request.auth.uid));
`,
'function requestUserIs(uid)': `
return request.auth.uid == uid;
`,
collectionGroups: {
'/posts/{postId}': {
allow: {
read: true,
},
},
},
'/users/{uid}': {
model: UserModel,
allow: {
read: true, // open access
write: rules.or('requestUserIs(uid)', 'isAdmin()'),
},
'/posts/{postId}': {
'function authorUidMatches()': `
return request.resource.data.authorUid == uid;
`,
model: PostModel,
allow: {
read: true,
write: rules.and('requestUserIs(uid)', 'authorUidMatches()'),
},
},
},
})
export default firestoreModel
Write rules are combined with the rules automatically generated from zod schema.
2. Generate firestore.rules
yarn fireschema rules <path-to-schema>.ts
Environment variable TS_NODE_PROJECT
is supported.
Example of generated firestore.rules
rules
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
function __validator_meta__(data) {
return (
(request.method == "create" && data._createdAt == request.time && data._updatedAt == request.time)
|| (request.method == "update" && data._createdAt == resource.data._createdAt && data._updatedAt == request.time)
);
}
function __validator_keys__(data, keys) {
return data.keys().removeAll(['_createdAt', '_updatedAt']).hasOnly(keys);
}
function isAdmin() {
return exists(/databases/$(database)/documents/admins/$(request.auth.uid));
}
function requestUserIs(uid) {
return request.auth.uid == uid;
}
match /{path=**}/posts/{postId} {
allow read: if true;
}
match /users/{uid} {
function __validator_0__(data) {
return (__validator_meta__(data) && (
__validator_keys__(data, ['name', 'displayName', 'age', 'timestamp', 'options'])
&& data.name is string
&& (data.displayName is string || data.displayName == null)
&& data.age is int
&& data.timestamp is timestamp
&& (data.options.a is bool || !("options" in data))
));
}
allow read: if true;
allow write: if ((requestUserIs(uid) || isAdmin()) && __validator_0__(request.resource.data));
match /posts/{postId} {
function authorUidMatches() {
return request.resource.data.authorUid == uid;
}
function __validator_1__(data) {
return (__validator_meta__(data) && (
__validator_keys__(data, ['authorUid', 'text', 'tags'])
&& data.authorUid is string
&& data.text is string
&& data.tags is list
));
}
allow read: if true;
allow write: if ((requestUserIs(uid) && authorUidMatches()) && __validator_1__(request.resource.data));
}
}
}
}
3. Read/write collections and documents
The Firestore interface of Fireschema supports both Web SDK and Admin SDK.
import { initializeApp } from 'firebase/app' // or firebase-admin
import { initializeFirestore } from 'firebase/firestore'
import { TypedFirestoreWeb } from 'fireschema'
import { firestoreModel } from './1-1-schema.js'
const app = initializeApp({
// ...
})
const firestoreApp = initializeFirestore(app, {
ignoreUndefinedProperties: true,
})
/**
* Initialize TypedFirestore
*/
export const $web: TypedFirestoreWeb<typeof firestoreModel> =
new TypedFirestoreWeb(firestoreModel, firestoreApp)
/**
* Reference collections/documents and get snapshot
*/
const usersRef = $web.collection('users') // TypedCollectionRef instance
const userRef = usersRef.doc('userId') // TypedDocumentRef instance
const postsRef = userRef.collection('posts')
const postRef = postsRef.doc('123')
const techPostsQuery = postsRef.select.byTag('tech') // selector defined in schema
await userRef.get() // TypedDocumentSnap<User>
await userRef.getData() // User | undefined
await userRef.getDataOrThrow() // User
await postRef.get() // TypedDocumentSnap<PostA | PostB>
await postsRef.get() // TypedQuerySnap<PostA | PostB>
await postsRef.getData() // (PostA | PostB)[]
await techPostsQuery.get() // TypedQuerySnap<PostA | PostB>
/**
* Get child collection of retrived document snapshot
*/
const snap = await usersRef.get()
const firstUserRef = snap.docs[0]!.ref
await firstUserRef.collection('posts').get()
/**
* Reference parent collection/document
*/
const _postsRef = postRef.parentCollection()
const _userRef = postsRef.parentDocument()
/**
* Reference collections groups and get snapshot
*/
const postsGroup = $web.collectionGroup('posts')
const techPostsGroup = postsGroup.select.byTag('tech')
await postsGroup.get() // TypedQuerySnap<PostA | PostB>
await techPostsGroup.get() // TypedQuerySnap<PostA | PostB>
/**
* Write data
*/
await userRef.create(({ serverTimestamp }) => ({
name: 'test',
displayName: 'Test',
age: 20,
timestamp: serverTimestamp(),
options: { a: true },
}))
await userRef.setMerge({
age: 21,
})
await userRef.update({
age: 21,
})
await userRef.delete()
/**
* Transaction
*/
await $web.runTransaction(async (tt) => {
const snap = await tt.get(userRef)
tt.update(userRef, {
age: snap.data()!.age + 1,
})
})
Write methods of Fireschema's document reference
create()
- Create a document. (_createdAt
/ _updatedAt
fields are added)
- Web - Call JS SDK's
set()
internally.
It fails if the document already exists because overwriting _createdAt is denied by the automatically generated security rules.
- Admin - Call Admin SDK's
create()
internally. It fails if the document already exists.
setMerge()
- Call set(data, { merge: true })
internally. (_updatedAt
field is updated)
update()
- Call update()
internally. (_updatedAt
field is updated)
set()
is not implemented on fireschema because it cannot determine whether _createdAt
should be included in update fields without specifying it is a new creation or an overwrite.
4. React Hooks
import React, { Suspense } from 'react'
import { useTypedCollection, useTypedDoc } from 'fireschema/hooks'
import { $web } from './1-3-typed-firestore.js'
/**
* Get realtime updates of collection/query
*/
export const PostsComponent = () => {
const userRef = $web.collection('users').doc('user1')
const postsRef = userRef.collection('posts')
const posts = useTypedCollection(postsRef)
const techPosts = useTypedCollection(postsRef.select.byTag('tech'))
return (
<Suspense fallback={'Loading...'}>
<ul>
{posts.data.map((post, i) => (
<li key={i}>{post.text}</li>
))}
</ul>
</Suspense>
)
}
/**
* Get realtime updates of document
*/
export const UserComponent = ({ id }: { id: string }) => {
const user = useTypedDoc($web.collection('users').doc(id))
return (
<Suspense fallback={'Loading...'}>
<span>{user.data?.displayName}</span>
</Suspense>
)
}