firestore-schema-kit 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/collection.js +98 -0
- package/fields.js +245 -0
- package/index.js +3 -0
- package/operations.js +419 -0
- package/package.json +27 -0
- package/readme.md +301 -0
package/collection.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { s } from './fields.js'
|
|
3
|
+
|
|
4
|
+
// ─── Schema Validation Error ───────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
export class SchemaValidationError extends Error {
|
|
7
|
+
constructor(collectionName, zodError) {
|
|
8
|
+
const issues = zodError.issues
|
|
9
|
+
.map((i) => ` • ${i.path.join('.')}: ${i.message}`)
|
|
10
|
+
.join('\n')
|
|
11
|
+
|
|
12
|
+
super(`[${collectionName}] Schema validation failed:\n${issues}`)
|
|
13
|
+
this.name = 'SchemaValidationError'
|
|
14
|
+
this.collection = collectionName
|
|
15
|
+
this.issues = zodError.issues
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ─── defineCollection ─────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Define a typed Firestore collection with schema validation.
|
|
23
|
+
*
|
|
24
|
+
* @param {string} collectionName - Firestore collection path (e.g. 'users', 'posts/comments')
|
|
25
|
+
* @param {(s: SchemaBuilder) => Record<string, BaseField>} builder - Field definition callback
|
|
26
|
+
* @returns {CollectionSchema}
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* export const usersSchema = defineCollection('users', (s) => ({
|
|
30
|
+
* name: s.string().min(1),
|
|
31
|
+
* email: s.string().email(),
|
|
32
|
+
* age: s.number().optional(),
|
|
33
|
+
* role: s.enum(['admin', 'user']).default('user'),
|
|
34
|
+
* createdAt: s.timestamp(),
|
|
35
|
+
* }))
|
|
36
|
+
*/
|
|
37
|
+
export function defineCollection(collectionName, builder) {
|
|
38
|
+
if (typeof collectionName !== 'string' || !collectionName.trim()) {
|
|
39
|
+
throw new Error('defineCollection: collectionName must be a non-empty string')
|
|
40
|
+
}
|
|
41
|
+
if (typeof builder !== 'function') {
|
|
42
|
+
throw new Error('defineCollection: builder must be a function')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Call builder with the schema builder (s)
|
|
46
|
+
const fieldDefs = builder(s)
|
|
47
|
+
|
|
48
|
+
if (!fieldDefs || typeof fieldDefs !== 'object') {
|
|
49
|
+
throw new Error('defineCollection: builder must return an object of field definitions')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Convert field definitions → Zod schema shape
|
|
53
|
+
const zodShape = {}
|
|
54
|
+
for (const [key, field] of Object.entries(fieldDefs)) {
|
|
55
|
+
if (typeof field?.toZod !== 'function') {
|
|
56
|
+
throw new Error(
|
|
57
|
+
`defineCollection [${collectionName}]: field "${key}" is not a valid schema field. ` +
|
|
58
|
+
`Use s.string(), s.number(), etc.`
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
zodShape[key] = field.toZod()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const zodSchema = z.object(zodShape)
|
|
65
|
+
const zodPartialSchema = zodSchema.partial()
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
// ── Metadata ────────────────────────────────────────────────────────────
|
|
69
|
+
_name: collectionName,
|
|
70
|
+
_fieldDefs: fieldDefs,
|
|
71
|
+
_type: 'CollectionSchema',
|
|
72
|
+
|
|
73
|
+
// ── Validation ──────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
/** Full validation — use for createDoc / setDoc */
|
|
76
|
+
validate(data) {
|
|
77
|
+
const result = zodSchema.safeParse(data)
|
|
78
|
+
if (!result.success) throw new SchemaValidationError(collectionName, result.error)
|
|
79
|
+
return result.data
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
/** Partial validation — use for updateDoc (only validates fields present in data) */
|
|
83
|
+
validatePartial(data) {
|
|
84
|
+
const result = zodPartialSchema.safeParse(data)
|
|
85
|
+
if (!result.success) throw new SchemaValidationError(collectionName, result.error)
|
|
86
|
+
return result.data
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
/** Safe (non-throwing) full validation */
|
|
90
|
+
safeParse(data) {
|
|
91
|
+
const result = zodSchema.safeParse(data)
|
|
92
|
+
if (!result.success) {
|
|
93
|
+
return { success: false, error: new SchemaValidationError(collectionName, result.error) }
|
|
94
|
+
}
|
|
95
|
+
return { success: true, data: result.data }
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
}
|
package/fields.js
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
// ─── Base Field ───────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
class BaseField {
|
|
6
|
+
constructor() {
|
|
7
|
+
this._optional = false
|
|
8
|
+
this._nullable = false
|
|
9
|
+
this._hasDefault = false
|
|
10
|
+
this._defaultVal = undefined
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
optional() {
|
|
14
|
+
this._optional = true
|
|
15
|
+
return this
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
nullable() {
|
|
19
|
+
this._nullable = true
|
|
20
|
+
return this
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
default(val) {
|
|
24
|
+
this._hasDefault = true
|
|
25
|
+
this._defaultVal = val
|
|
26
|
+
return this
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Subclasses implement this — returns a raw Zod schema with no optional/default wrapping
|
|
30
|
+
_baseZod() {
|
|
31
|
+
throw new Error(`_baseZod() not implemented on ${this.constructor.name}`)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Builds the final Zod schema, applying nullable → default → optional in the correct order
|
|
35
|
+
toZod() {
|
|
36
|
+
let schema = this._baseZod()
|
|
37
|
+
if (this._nullable) schema = schema.nullable()
|
|
38
|
+
if (this._hasDefault) schema = schema.default(this._defaultVal)
|
|
39
|
+
if (this._optional) schema = schema.optional()
|
|
40
|
+
return schema
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ─── String ───────────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
export class StringField extends BaseField {
|
|
47
|
+
constructor() {
|
|
48
|
+
super()
|
|
49
|
+
this._min = null
|
|
50
|
+
this._max = null
|
|
51
|
+
this._email = false
|
|
52
|
+
this._url = false
|
|
53
|
+
this._uuid = false
|
|
54
|
+
this._regex = null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
min(n) { this._min = n; return this }
|
|
58
|
+
max(n) { this._max = n; return this }
|
|
59
|
+
email() { this._email = true; return this }
|
|
60
|
+
url() { this._url = true; return this }
|
|
61
|
+
uuid() { this._uuid = true; return this }
|
|
62
|
+
regex(r) { this._regex = r; return this }
|
|
63
|
+
|
|
64
|
+
_baseZod() {
|
|
65
|
+
let schema = z.string()
|
|
66
|
+
if (this._min !== null) schema = schema.min(this._min)
|
|
67
|
+
if (this._max !== null) schema = schema.max(this._max)
|
|
68
|
+
if (this._email) schema = schema.email()
|
|
69
|
+
if (this._url) schema = schema.url()
|
|
70
|
+
if (this._uuid) schema = schema.uuid()
|
|
71
|
+
if (this._regex !== null) schema = schema.regex(this._regex)
|
|
72
|
+
return schema
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ─── Number ───────────────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
export class NumberField extends BaseField {
|
|
79
|
+
constructor() {
|
|
80
|
+
super()
|
|
81
|
+
this._min = null
|
|
82
|
+
this._max = null
|
|
83
|
+
this._int = false
|
|
84
|
+
this._positive = false
|
|
85
|
+
this._negative = false
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
min(n) { this._min = n; return this }
|
|
89
|
+
max(n) { this._max = n; return this }
|
|
90
|
+
int() { this._int = true; return this }
|
|
91
|
+
positive() { this._positive = true; return this }
|
|
92
|
+
negative() { this._negative = true; return this }
|
|
93
|
+
|
|
94
|
+
_baseZod() {
|
|
95
|
+
let schema = this._int ? z.number().int() : z.number()
|
|
96
|
+
if (this._min !== null) schema = schema.min(this._min)
|
|
97
|
+
if (this._max !== null) schema = schema.max(this._max)
|
|
98
|
+
if (this._positive) schema = schema.positive()
|
|
99
|
+
if (this._negative) schema = schema.negative()
|
|
100
|
+
return schema
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ─── Boolean ──────────────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
export class BooleanField extends BaseField {
|
|
107
|
+
_baseZod() {
|
|
108
|
+
return z.boolean()
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ─── Timestamp ────────────────────────────────────────────────────────────────
|
|
113
|
+
// Accepts: JS Date, Firestore Timestamp, serverTimestamp() sentinel
|
|
114
|
+
|
|
115
|
+
export class TimestampField extends BaseField {
|
|
116
|
+
_baseZod() {
|
|
117
|
+
return z.custom(
|
|
118
|
+
(val) => {
|
|
119
|
+
if (!val) return false
|
|
120
|
+
// JS Date
|
|
121
|
+
if (val instanceof Date) return true
|
|
122
|
+
// Firestore Timestamp (has toDate method)
|
|
123
|
+
if (typeof val.toDate === 'function') return true
|
|
124
|
+
// serverTimestamp() sentinel — has _methodName internally
|
|
125
|
+
if (typeof val === 'object' && '_methodName' in val) return true
|
|
126
|
+
return false
|
|
127
|
+
},
|
|
128
|
+
{ message: 'Expected a Date, Firestore Timestamp, or serverTimestamp()' }
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ─── Array ────────────────────────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
export class ArrayField extends BaseField {
|
|
136
|
+
constructor(itemField) {
|
|
137
|
+
super()
|
|
138
|
+
this._itemField = itemField
|
|
139
|
+
this._min = null
|
|
140
|
+
this._max = null
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
min(n) { this._min = n; return this }
|
|
144
|
+
max(n) { this._max = n; return this }
|
|
145
|
+
|
|
146
|
+
_baseZod() {
|
|
147
|
+
let schema = z.array(this._itemField.toZod())
|
|
148
|
+
if (this._min !== null) schema = schema.min(this._min)
|
|
149
|
+
if (this._max !== null) schema = schema.max(this._max)
|
|
150
|
+
return schema
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ─── Map (nested object) ──────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
export class MapField extends BaseField {
|
|
157
|
+
constructor(shape) {
|
|
158
|
+
super()
|
|
159
|
+
this._shape = shape
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
_baseZod() {
|
|
163
|
+
const zodShape = {}
|
|
164
|
+
for (const [key, field] of Object.entries(this._shape)) {
|
|
165
|
+
zodShape[key] = field.toZod()
|
|
166
|
+
}
|
|
167
|
+
return z.object(zodShape)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ─── Enum ─────────────────────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
export class EnumField extends BaseField {
|
|
174
|
+
constructor(values) {
|
|
175
|
+
super()
|
|
176
|
+
this._values = values
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
_baseZod() {
|
|
180
|
+
return z.enum(this._values)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ─── Literal ──────────────────────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
export class LiteralField extends BaseField {
|
|
187
|
+
constructor(value) {
|
|
188
|
+
super()
|
|
189
|
+
this._value = value
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
_baseZod() {
|
|
193
|
+
return z.literal(this._value)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ─── Reference (Firestore DocumentReference) ──────────────────────────────────
|
|
198
|
+
|
|
199
|
+
export class ReferenceField extends BaseField {
|
|
200
|
+
_baseZod() {
|
|
201
|
+
return z.custom(
|
|
202
|
+
(val) => val && typeof val.path === 'string' && typeof val.id === 'string',
|
|
203
|
+
{ message: 'Expected a Firestore DocumentReference' }
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ─── Union ────────────────────────────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
export class UnionField extends BaseField {
|
|
211
|
+
constructor(fields) {
|
|
212
|
+
super()
|
|
213
|
+
this._fields = fields
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
_baseZod() {
|
|
217
|
+
const schemas = this._fields.map((f) => f.toZod())
|
|
218
|
+
return z.union(schemas)
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ─── Any (escape hatch) ───────────────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
export class AnyField extends BaseField {
|
|
225
|
+
_baseZod() {
|
|
226
|
+
return z.any()
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ─── Schema Builder (s) ───────────────────────────────────────────────────────
|
|
231
|
+
// This is what gets passed into the defineCollection callback
|
|
232
|
+
|
|
233
|
+
export const s = {
|
|
234
|
+
string: () => new StringField(),
|
|
235
|
+
number: () => new NumberField(),
|
|
236
|
+
boolean: () => new BooleanField(),
|
|
237
|
+
timestamp: () => new TimestampField(),
|
|
238
|
+
array: (itemField) => new ArrayField(itemField),
|
|
239
|
+
map: (shape) => new MapField(shape),
|
|
240
|
+
enum: (values) => new EnumField(values),
|
|
241
|
+
literal: (value) => new LiteralField(value),
|
|
242
|
+
reference: () => new ReferenceField(),
|
|
243
|
+
union: (fields) => new UnionField(fields),
|
|
244
|
+
any: () => new AnyField(),
|
|
245
|
+
}
|
package/index.js
ADDED
package/operations.js
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import {
|
|
2
|
+
collection,
|
|
3
|
+
doc,
|
|
4
|
+
addDoc,
|
|
5
|
+
setDoc as _setDoc,
|
|
6
|
+
updateDoc as _updateDoc,
|
|
7
|
+
deleteDoc as _deleteDoc,
|
|
8
|
+
getDoc as _getDoc,
|
|
9
|
+
getDocs as _getDocs,
|
|
10
|
+
onSnapshot as _onSnapshot,
|
|
11
|
+
query,
|
|
12
|
+
writeBatch,
|
|
13
|
+
runTransaction as _runTransaction,
|
|
14
|
+
serverTimestamp,
|
|
15
|
+
Timestamp,
|
|
16
|
+
} from 'firebase/firestore'
|
|
17
|
+
|
|
18
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Validate that `schema` is a CollectionSchema produced by defineCollection().
|
|
22
|
+
*/
|
|
23
|
+
function assertSchema(schema, opName) {
|
|
24
|
+
if (!schema || schema._type !== 'CollectionSchema') {
|
|
25
|
+
throw new TypeError(
|
|
26
|
+
`${opName}: first argument must be a CollectionSchema from defineCollection(). Got: ${typeof schema}`
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolve a string ID or an existing DocumentReference into a DocumentReference.
|
|
33
|
+
*/
|
|
34
|
+
function resolveDocRef(db, schema, idOrRef) {
|
|
35
|
+
if (typeof idOrRef === 'string') {
|
|
36
|
+
return doc(db, schema._name, idOrRef)
|
|
37
|
+
}
|
|
38
|
+
// Already a DocumentReference
|
|
39
|
+
if (idOrRef && typeof idOrRef.path === 'string') {
|
|
40
|
+
return idOrRef
|
|
41
|
+
}
|
|
42
|
+
throw new TypeError(
|
|
43
|
+
`Expected a document ID (string) or DocumentReference, got: ${typeof idOrRef}`
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Get a CollectionReference for the schema.
|
|
49
|
+
*/
|
|
50
|
+
function collectionRef(db, schema) {
|
|
51
|
+
return collection(db, schema._name)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Map a Firestore DocumentSnapshot → plain data object with `id` field attached.
|
|
56
|
+
* Returns null if the document doesn't exist.
|
|
57
|
+
*/
|
|
58
|
+
function docToData(snapshot) {
|
|
59
|
+
if (!snapshot.exists()) return null
|
|
60
|
+
return { id: snapshot.id, ...snapshot.data() }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Map a QuerySnapshot → array of plain data objects with `id` field attached.
|
|
65
|
+
*/
|
|
66
|
+
function snapshotToArray(querySnapshot) {
|
|
67
|
+
return querySnapshot.docs.map((d) => ({ id: d.id, ...d.data() }))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ─── Factory ─────────────────────────────────────────────────────────────────
|
|
71
|
+
// All operations are created via initFirestoreSchema(db) so db doesn't need
|
|
72
|
+
// to be passed on every call.
|
|
73
|
+
|
|
74
|
+
export function initFirestoreSchema(db) {
|
|
75
|
+
if (!db) throw new Error('initFirestoreSchema: db (Firestore instance) is required')
|
|
76
|
+
|
|
77
|
+
// ── createDoc ─────────────────────────────────────────────────────────────
|
|
78
|
+
/**
|
|
79
|
+
* Add a new document with an auto-generated ID.
|
|
80
|
+
* Equivalent to Firestore's addDoc().
|
|
81
|
+
*
|
|
82
|
+
* @returns {Promise<DocumentReference>}
|
|
83
|
+
*
|
|
84
|
+
* @example
|
|
85
|
+
* const ref = await createDoc(usersSchema, {
|
|
86
|
+
* name: 'Alice',
|
|
87
|
+
* email: 'alice@example.com',
|
|
88
|
+
* createdAt: serverTimestamp(),
|
|
89
|
+
* })
|
|
90
|
+
* console.log(ref.id) // auto-generated ID
|
|
91
|
+
*/
|
|
92
|
+
async function createDoc(schema, data) {
|
|
93
|
+
assertSchema(schema, 'createDoc')
|
|
94
|
+
const validated = schema.validate(data)
|
|
95
|
+
return addDoc(collectionRef(db, schema), validated)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── setDoc ────────────────────────────────────────────────────────────────
|
|
99
|
+
/**
|
|
100
|
+
* Write a document with a specific ID (overwrites by default).
|
|
101
|
+
* Pass { merge: true } to merge instead of overwrite.
|
|
102
|
+
* Equivalent to Firestore's setDoc().
|
|
103
|
+
*
|
|
104
|
+
* @param {CollectionSchema} schema
|
|
105
|
+
* @param {string | DocumentReference} idOrRef
|
|
106
|
+
* @param {object} data
|
|
107
|
+
* @param {{ merge?: boolean, mergeFields?: string[] }} [options]
|
|
108
|
+
*
|
|
109
|
+
* @example
|
|
110
|
+
* await setDoc(usersSchema, 'user-123', { name: 'Alice', email: 'alice@example.com', createdAt: serverTimestamp() })
|
|
111
|
+
* await setDoc(usersSchema, 'user-123', { name: 'Alice Updated' }, { merge: true })
|
|
112
|
+
*/
|
|
113
|
+
async function setDoc(schema, idOrRef, data, options = {}) {
|
|
114
|
+
assertSchema(schema, 'setDoc')
|
|
115
|
+
const ref = resolveDocRef(db, schema, idOrRef)
|
|
116
|
+
// merge/mergeFields: only validate fields present in data (partial)
|
|
117
|
+
const validated = (options.merge || options.mergeFields)
|
|
118
|
+
? schema.validatePartial(data)
|
|
119
|
+
: schema.validate(data)
|
|
120
|
+
return _setDoc(ref, validated, options)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── updateDoc ─────────────────────────────────────────────────────────────
|
|
124
|
+
/**
|
|
125
|
+
* Partially update a document — only fields provided will be written.
|
|
126
|
+
* The document must already exist.
|
|
127
|
+
* Equivalent to Firestore's updateDoc().
|
|
128
|
+
*
|
|
129
|
+
* @example
|
|
130
|
+
* await updateDoc(usersSchema, 'user-123', { name: 'Alice Updated' })
|
|
131
|
+
*/
|
|
132
|
+
async function updateDoc(schema, idOrRef, data) {
|
|
133
|
+
assertSchema(schema, 'updateDoc')
|
|
134
|
+
const ref = resolveDocRef(db, schema, idOrRef)
|
|
135
|
+
const validated = schema.validatePartial(data)
|
|
136
|
+
return _updateDoc(ref, validated)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── deleteDoc ─────────────────────────────────────────────────────────────
|
|
140
|
+
/**
|
|
141
|
+
* Delete a document by ID or ref.
|
|
142
|
+
* No schema validation needed — it's just a deletion.
|
|
143
|
+
*
|
|
144
|
+
* @example
|
|
145
|
+
* await deleteDoc(usersSchema, 'user-123')
|
|
146
|
+
*/
|
|
147
|
+
async function deleteDoc(schema, idOrRef) {
|
|
148
|
+
assertSchema(schema, 'deleteDoc')
|
|
149
|
+
const ref = resolveDocRef(db, schema, idOrRef)
|
|
150
|
+
return _deleteDoc(ref)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── getDoc ────────────────────────────────────────────────────────────────
|
|
154
|
+
/**
|
|
155
|
+
* Fetch a single document by ID or ref.
|
|
156
|
+
* Returns { id, ...data } or null if not found.
|
|
157
|
+
*
|
|
158
|
+
* @returns {Promise<{ id: string } & T | null>}
|
|
159
|
+
*
|
|
160
|
+
* @example
|
|
161
|
+
* const user = await getDoc(usersSchema, 'user-123')
|
|
162
|
+
* if (!user) console.log('Not found')
|
|
163
|
+
*/
|
|
164
|
+
async function getDoc(schema, idOrRef) {
|
|
165
|
+
assertSchema(schema, 'getDoc')
|
|
166
|
+
const ref = resolveDocRef(db, schema, idOrRef)
|
|
167
|
+
const snapshot = await _getDoc(ref)
|
|
168
|
+
return docToData(snapshot)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ── getDocs ───────────────────────────────────────────────────────────────
|
|
172
|
+
/**
|
|
173
|
+
* Fetch multiple documents from a collection, with optional query constraints.
|
|
174
|
+
* Returns an array of { id, ...data } objects.
|
|
175
|
+
*
|
|
176
|
+
* @param {CollectionSchema} schema
|
|
177
|
+
* @param {...QueryConstraint} constraints - where(), orderBy(), limit(), startAfter(), etc.
|
|
178
|
+
* @returns {Promise<Array<{ id: string } & T>>}
|
|
179
|
+
*
|
|
180
|
+
* @example
|
|
181
|
+
* import { where, orderBy, limit } from 'firebase/firestore'
|
|
182
|
+
*
|
|
183
|
+
* const users = await getDocs(usersSchema)
|
|
184
|
+
* const admins = await getDocs(usersSchema, where('role', '==', 'admin'))
|
|
185
|
+
* const recent = await getDocs(usersSchema, orderBy('createdAt', 'desc'), limit(10))
|
|
186
|
+
*/
|
|
187
|
+
async function getDocs(schema, ...constraints) {
|
|
188
|
+
assertSchema(schema, 'getDocs')
|
|
189
|
+
const ref = collectionRef(db, schema)
|
|
190
|
+
const q = constraints.length ? query(ref, ...constraints) : ref
|
|
191
|
+
const snapshot = await _getDocs(q)
|
|
192
|
+
return snapshotToArray(snapshot)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ── getDocRef ─────────────────────────────────────────────────────────────
|
|
196
|
+
/**
|
|
197
|
+
* Get a Firestore DocumentReference for a given schema and ID.
|
|
198
|
+
* Useful when you need the ref itself (e.g. for storing references).
|
|
199
|
+
*
|
|
200
|
+
* @example
|
|
201
|
+
* const ref = getDocRef(usersSchema, 'user-123')
|
|
202
|
+
* await setDoc(postsSchema, 'post-1', { author: ref, ... })
|
|
203
|
+
*/
|
|
204
|
+
function getDocRef(schema, id) {
|
|
205
|
+
assertSchema(schema, 'getDocRef')
|
|
206
|
+
return doc(db, schema._name, id)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Get a Firestore CollectionReference for a schema.
|
|
211
|
+
*
|
|
212
|
+
* @example
|
|
213
|
+
* const ref = getCollectionRef(usersSchema)
|
|
214
|
+
*/
|
|
215
|
+
function getCollectionRef(schema) {
|
|
216
|
+
assertSchema(schema, 'getCollectionRef')
|
|
217
|
+
return collectionRef(db, schema)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ── onDocSnapshot ─────────────────────────────────────────────────────────
|
|
221
|
+
/**
|
|
222
|
+
* Listen to real-time updates on a single document.
|
|
223
|
+
* Returns an unsubscribe function.
|
|
224
|
+
*
|
|
225
|
+
* @param {CollectionSchema} schema
|
|
226
|
+
* @param {string | DocumentReference} idOrRef
|
|
227
|
+
* @param {(data: T | null) => void} callback - called with { id, ...data } or null
|
|
228
|
+
* @param {(error: Error) => void} [onError]
|
|
229
|
+
* @returns {Unsubscribe}
|
|
230
|
+
*
|
|
231
|
+
* @example
|
|
232
|
+
* const unsub = onDocSnapshot(usersSchema, 'user-123', (user) => {
|
|
233
|
+
* if (user) console.log(user.name)
|
|
234
|
+
* })
|
|
235
|
+
* // Later: unsub()
|
|
236
|
+
*/
|
|
237
|
+
function onDocSnapshot(schema, idOrRef, callback, onError) {
|
|
238
|
+
assertSchema(schema, 'onDocSnapshot')
|
|
239
|
+
const ref = resolveDocRef(db, schema, idOrRef)
|
|
240
|
+
return _onSnapshot(
|
|
241
|
+
ref,
|
|
242
|
+
(snapshot) => callback(docToData(snapshot)),
|
|
243
|
+
onError
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ── onCollectionSnapshot ──────────────────────────────────────────────────
|
|
248
|
+
/**
|
|
249
|
+
* Listen to real-time updates on a collection or query.
|
|
250
|
+
* Returns an unsubscribe function.
|
|
251
|
+
*
|
|
252
|
+
* @param {CollectionSchema} schema
|
|
253
|
+
* @param {(docs: Array<T>) => void} callback - called with array of { id, ...data }
|
|
254
|
+
* @param {...QueryConstraint} constraints - optional where(), orderBy(), limit(), etc.
|
|
255
|
+
* @returns {Unsubscribe}
|
|
256
|
+
*
|
|
257
|
+
* @example
|
|
258
|
+
* const unsub = onCollectionSnapshot(
|
|
259
|
+
* usersSchema,
|
|
260
|
+
* (users) => console.log(users),
|
|
261
|
+
* where('role', '==', 'admin'),
|
|
262
|
+
* orderBy('name')
|
|
263
|
+
* )
|
|
264
|
+
* // Later: unsub()
|
|
265
|
+
*/
|
|
266
|
+
function onCollectionSnapshot(schema, callback, ...constraints) {
|
|
267
|
+
assertSchema(schema, 'onCollectionSnapshot')
|
|
268
|
+
const ref = collectionRef(db, schema)
|
|
269
|
+
const q = constraints.length ? query(ref, ...constraints) : ref
|
|
270
|
+
return _onSnapshot(
|
|
271
|
+
q,
|
|
272
|
+
(snapshot) => callback(snapshotToArray(snapshot)),
|
|
273
|
+
)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ── Batch Writes ──────────────────────────────────────────────────────────
|
|
277
|
+
/**
|
|
278
|
+
* Create a schema-aware write batch.
|
|
279
|
+
* All operations are validated before being added to the batch.
|
|
280
|
+
*
|
|
281
|
+
* @example
|
|
282
|
+
* const batch = createBatch()
|
|
283
|
+
*
|
|
284
|
+
* batch.create(usersSchema, { name: 'Alice', email: 'alice@example.com', createdAt: serverTimestamp() })
|
|
285
|
+
* batch.set(usersSchema, 'user-456', { name: 'Bob', email: 'bob@example.com', createdAt: serverTimestamp() })
|
|
286
|
+
* batch.update(usersSchema, 'user-123', { name: 'Alice Updated' })
|
|
287
|
+
* batch.delete(usersSchema, 'user-789')
|
|
288
|
+
*
|
|
289
|
+
* await batch.commit()
|
|
290
|
+
*/
|
|
291
|
+
function createBatch() {
|
|
292
|
+
const batch = writeBatch(db)
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
/**
|
|
296
|
+
* Add a new document with auto-generated ID to the batch.
|
|
297
|
+
* Note: Firestore's batch doesn't support addDoc — this uses doc() with a generated ref.
|
|
298
|
+
*/
|
|
299
|
+
create(schema, data) {
|
|
300
|
+
assertSchema(schema, 'batch.create')
|
|
301
|
+
const validated = schema.validate(data)
|
|
302
|
+
const ref = doc(collectionRef(db, schema))
|
|
303
|
+
batch.set(ref, validated)
|
|
304
|
+
return ref // return ref so caller can use the ID
|
|
305
|
+
},
|
|
306
|
+
|
|
307
|
+
/** Set (overwrite) a document in the batch */
|
|
308
|
+
set(schema, idOrRef, data, options = {}) {
|
|
309
|
+
assertSchema(schema, 'batch.set')
|
|
310
|
+
const ref = resolveDocRef(db, schema, idOrRef)
|
|
311
|
+
const validated = (options.merge || options.mergeFields)
|
|
312
|
+
? schema.validatePartial(data)
|
|
313
|
+
: schema.validate(data)
|
|
314
|
+
batch.set(ref, validated, options)
|
|
315
|
+
return this
|
|
316
|
+
},
|
|
317
|
+
|
|
318
|
+
/** Partially update a document in the batch */
|
|
319
|
+
update(schema, idOrRef, data) {
|
|
320
|
+
assertSchema(schema, 'batch.update')
|
|
321
|
+
const ref = resolveDocRef(db, schema, idOrRef)
|
|
322
|
+
const validated = schema.validatePartial(data)
|
|
323
|
+
batch.update(ref, validated)
|
|
324
|
+
return this
|
|
325
|
+
},
|
|
326
|
+
|
|
327
|
+
/** Delete a document in the batch */
|
|
328
|
+
delete(schema, idOrRef) {
|
|
329
|
+
assertSchema(schema, 'batch.delete')
|
|
330
|
+
const ref = resolveDocRef(db, schema, idOrRef)
|
|
331
|
+
batch.delete(ref)
|
|
332
|
+
return this
|
|
333
|
+
},
|
|
334
|
+
|
|
335
|
+
/** Commit all batched operations */
|
|
336
|
+
commit() {
|
|
337
|
+
return batch.commit()
|
|
338
|
+
},
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ── Transactions ──────────────────────────────────────────────────────────
|
|
343
|
+
/**
|
|
344
|
+
* Run a schema-aware transaction.
|
|
345
|
+
* The transaction callback receives a schema-aware transaction object.
|
|
346
|
+
*
|
|
347
|
+
* @param {(t: SchemaTransaction) => Promise<T>} updateFn
|
|
348
|
+
* @returns {Promise<T>}
|
|
349
|
+
*
|
|
350
|
+
* @example
|
|
351
|
+
* await runTransaction(async (t) => {
|
|
352
|
+
* const user = await t.get(usersSchema, 'user-123')
|
|
353
|
+
* if (!user) throw new Error('User not found')
|
|
354
|
+
*
|
|
355
|
+
* await t.update(usersSchema, 'user-123', {
|
|
356
|
+
* points: user.points + 10
|
|
357
|
+
* })
|
|
358
|
+
* })
|
|
359
|
+
*/
|
|
360
|
+
async function runTransaction(updateFn) {
|
|
361
|
+
return _runTransaction(db, (firestoreTx) => {
|
|
362
|
+
const t = {
|
|
363
|
+
/** Get a document within the transaction. Returns { id, ...data } or null. */
|
|
364
|
+
async get(schema, idOrRef) {
|
|
365
|
+
assertSchema(schema, 'transaction.get')
|
|
366
|
+
const ref = resolveDocRef(db, schema, idOrRef)
|
|
367
|
+
const snapshot = await firestoreTx.get(ref)
|
|
368
|
+
return docToData(snapshot)
|
|
369
|
+
},
|
|
370
|
+
|
|
371
|
+
/** Set (overwrite) a document within the transaction */
|
|
372
|
+
set(schema, idOrRef, data, options = {}) {
|
|
373
|
+
assertSchema(schema, 'transaction.set')
|
|
374
|
+
const ref = resolveDocRef(db, schema, idOrRef)
|
|
375
|
+
const validated = (options.merge || options.mergeFields)
|
|
376
|
+
? schema.validatePartial(data)
|
|
377
|
+
: schema.validate(data)
|
|
378
|
+
firestoreTx.set(ref, validated, options)
|
|
379
|
+
return this
|
|
380
|
+
},
|
|
381
|
+
|
|
382
|
+
/** Partially update a document within the transaction */
|
|
383
|
+
update(schema, idOrRef, data) {
|
|
384
|
+
assertSchema(schema, 'transaction.update')
|
|
385
|
+
const ref = resolveDocRef(db, schema, idOrRef)
|
|
386
|
+
const validated = schema.validatePartial(data)
|
|
387
|
+
firestoreTx.update(ref, validated)
|
|
388
|
+
return this
|
|
389
|
+
},
|
|
390
|
+
|
|
391
|
+
/** Delete a document within the transaction */
|
|
392
|
+
delete(schema, idOrRef) {
|
|
393
|
+
assertSchema(schema, 'transaction.delete')
|
|
394
|
+
const ref = resolveDocRef(db, schema, idOrRef)
|
|
395
|
+
firestoreTx.delete(ref)
|
|
396
|
+
return this
|
|
397
|
+
},
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return updateFn(t)
|
|
401
|
+
})
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// ── Return all operations ─────────────────────────────────────────────────
|
|
405
|
+
return {
|
|
406
|
+
createDoc,
|
|
407
|
+
setDoc,
|
|
408
|
+
updateDoc,
|
|
409
|
+
deleteDoc,
|
|
410
|
+
getDoc,
|
|
411
|
+
getDocs,
|
|
412
|
+
getDocRef,
|
|
413
|
+
getCollectionRef,
|
|
414
|
+
onDocSnapshot,
|
|
415
|
+
onCollectionSnapshot,
|
|
416
|
+
createBatch,
|
|
417
|
+
runTransaction,
|
|
418
|
+
}
|
|
419
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "firestore-schema-kit",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"main": "index.js",
|
|
5
|
+
"exports": "./index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
9
|
+
},
|
|
10
|
+
"keywords": [],
|
|
11
|
+
"author": "Bonanza Narayan",
|
|
12
|
+
"license": "ISC",
|
|
13
|
+
"description": "Drizzle-style schema validation for Firebase Firestore.",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/BonanzaNarayan/firebase-schema.git"
|
|
17
|
+
},
|
|
18
|
+
"bugs": {
|
|
19
|
+
"url": "https://github.com/BonanzaNarayan/firebase-schema/issues"
|
|
20
|
+
},
|
|
21
|
+
"homepage": "https://github.com/BonanzaNarayan/firebase-schema#readme",
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"firebase": "^12.11.0",
|
|
24
|
+
"zod": "^4.3.6"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {}
|
|
27
|
+
}
|
package/readme.md
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
# firebase-schema
|
|
2
|
+
|
|
3
|
+
Drizzle-style schema validation for Firebase Firestore. Define your collections once, get validated reads and writes everywhere — with zero schema leaking to your app layer.
|
|
4
|
+
|
|
5
|
+
Zod handles validation internally. You never touch Zod directly.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install zod firebase
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Copy `src/` into your project (e.g. `lib/firebase-schema/`).
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Quick start
|
|
20
|
+
|
|
21
|
+
### 1. Define your schemas
|
|
22
|
+
|
|
23
|
+
```js
|
|
24
|
+
// schema.js
|
|
25
|
+
import { defineCollection } from './lib/firebase-schema'
|
|
26
|
+
|
|
27
|
+
export const usersSchema = defineCollection('users', (s) => ({
|
|
28
|
+
name: s.string().min(1),
|
|
29
|
+
email: s.string().email(),
|
|
30
|
+
role: s.enum(['admin', 'user']).default('user'),
|
|
31
|
+
age: s.number().int().optional(),
|
|
32
|
+
isActive: s.boolean().default(true),
|
|
33
|
+
createdAt: s.timestamp(),
|
|
34
|
+
updatedAt: s.timestamp(),
|
|
35
|
+
}))
|
|
36
|
+
|
|
37
|
+
export const postsSchema = defineCollection('posts', (s) => ({
|
|
38
|
+
title: s.string().min(1).max(200),
|
|
39
|
+
body: s.string(),
|
|
40
|
+
authorRef: s.reference(),
|
|
41
|
+
tags: s.array(s.string()).default([]),
|
|
42
|
+
status: s.enum(['draft', 'published']).default('draft'),
|
|
43
|
+
createdAt: s.timestamp(),
|
|
44
|
+
}))
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### 2. Init Firebase and bind operations
|
|
48
|
+
|
|
49
|
+
```js
|
|
50
|
+
// firebase.js
|
|
51
|
+
import { initializeApp } from 'firebase/app'
|
|
52
|
+
import { getFirestore } from 'firebase/firestore'
|
|
53
|
+
import { initFirestoreSchema } from './lib/firebase-schema'
|
|
54
|
+
|
|
55
|
+
const app = initializeApp({ /* your config */ })
|
|
56
|
+
const db = getFirestore(app)
|
|
57
|
+
|
|
58
|
+
export const {
|
|
59
|
+
createDoc,
|
|
60
|
+
setDoc,
|
|
61
|
+
updateDoc,
|
|
62
|
+
deleteDoc,
|
|
63
|
+
getDoc,
|
|
64
|
+
getDocs,
|
|
65
|
+
getDocRef,
|
|
66
|
+
getCollectionRef,
|
|
67
|
+
onDocSnapshot,
|
|
68
|
+
onCollectionSnapshot,
|
|
69
|
+
createBatch,
|
|
70
|
+
runTransaction,
|
|
71
|
+
} = initFirestoreSchema(db)
|
|
72
|
+
|
|
73
|
+
export { db }
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### 3. Use everywhere
|
|
77
|
+
|
|
78
|
+
```js
|
|
79
|
+
import { serverTimestamp, where, orderBy, limit } from 'firebase/firestore'
|
|
80
|
+
import { createDoc, updateDoc, getDocs } from './firebase.js'
|
|
81
|
+
import { usersSchema } from './schema.js'
|
|
82
|
+
|
|
83
|
+
// Create
|
|
84
|
+
await createDoc(usersSchema, {
|
|
85
|
+
name: 'Alice',
|
|
86
|
+
email: 'alice@example.com',
|
|
87
|
+
createdAt: serverTimestamp(),
|
|
88
|
+
updatedAt: serverTimestamp(),
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
// Update (partial — only validates fields you provide)
|
|
92
|
+
await updateDoc(usersSchema, 'user-123', {
|
|
93
|
+
name: 'Alice Smith',
|
|
94
|
+
updatedAt: serverTimestamp(),
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
// Query
|
|
98
|
+
const admins = await getDocs(
|
|
99
|
+
usersSchema,
|
|
100
|
+
where('role', '==', 'admin'),
|
|
101
|
+
orderBy('name'),
|
|
102
|
+
limit(20)
|
|
103
|
+
)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## Field types
|
|
109
|
+
|
|
110
|
+
| Field | Example | Options |
|
|
111
|
+
|-------|---------|---------|
|
|
112
|
+
| `s.string()` | `s.string().min(1).max(100)` | `.min()` `.max()` `.email()` `.url()` `.uuid()` `.regex()` |
|
|
113
|
+
| `s.number()` | `s.number().int().positive()` | `.min()` `.max()` `.int()` `.positive()` `.negative()` |
|
|
114
|
+
| `s.boolean()` | `s.boolean().default(false)` | — |
|
|
115
|
+
| `s.timestamp()` | `s.timestamp()` | Accepts `Date`, Firestore `Timestamp`, `serverTimestamp()` |
|
|
116
|
+
| `s.array(item)` | `s.array(s.string()).min(1)` | `.min()` `.max()` |
|
|
117
|
+
| `s.map(shape)` | `s.map({ x: s.number(), y: s.number() })` | Nested schema |
|
|
118
|
+
| `s.enum(values)` | `s.enum(['a', 'b', 'c'])` | — |
|
|
119
|
+
| `s.reference()` | `s.reference()` | Accepts `DocumentReference` |
|
|
120
|
+
| `s.literal(val)` | `s.literal('active')` | — |
|
|
121
|
+
| `s.union(fields)` | `s.union([s.string(), s.number()])` | — |
|
|
122
|
+
| `s.any()` | `s.any()` | Escape hatch |
|
|
123
|
+
|
|
124
|
+
### Modifiers (available on all field types)
|
|
125
|
+
|
|
126
|
+
```js
|
|
127
|
+
s.string().optional() // field can be absent from the object
|
|
128
|
+
s.string().nullable() // field can be null
|
|
129
|
+
s.string().default('hello') // use this value if field is absent
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
## All operations
|
|
135
|
+
|
|
136
|
+
### `createDoc(schema, data)` — addDoc equivalent
|
|
137
|
+
Auto-generated ID. Full schema validation.
|
|
138
|
+
|
|
139
|
+
```js
|
|
140
|
+
const ref = await createDoc(usersSchema, { name: 'Alice', email: 'alice@example.com', createdAt: serverTimestamp(), updatedAt: serverTimestamp() })
|
|
141
|
+
console.log(ref.id)
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### `setDoc(schema, id, data, options?)` — setDoc equivalent
|
|
145
|
+
Specific ID. Full validation. Pass `{ merge: true }` to merge (partial validation).
|
|
146
|
+
|
|
147
|
+
```js
|
|
148
|
+
await setDoc(usersSchema, 'user-123', { name: 'Alice', email: 'alice@example.com', createdAt: serverTimestamp(), updatedAt: serverTimestamp() })
|
|
149
|
+
await setDoc(usersSchema, 'user-123', { bio: 'Hello' }, { merge: true })
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### `updateDoc(schema, id, data)` — updateDoc equivalent
|
|
153
|
+
Partial update. Only validates the fields you provide. Document must exist.
|
|
154
|
+
|
|
155
|
+
```js
|
|
156
|
+
await updateDoc(usersSchema, 'user-123', { name: 'Alice Updated', updatedAt: serverTimestamp() })
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### `deleteDoc(schema, id)` — deleteDoc equivalent
|
|
160
|
+
|
|
161
|
+
```js
|
|
162
|
+
await deleteDoc(usersSchema, 'user-123')
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### `getDoc(schema, id)` — getDoc equivalent
|
|
166
|
+
Returns `{ id, ...data }` or `null`.
|
|
167
|
+
|
|
168
|
+
```js
|
|
169
|
+
const user = await getDoc(usersSchema, 'user-123')
|
|
170
|
+
if (!user) return // not found
|
|
171
|
+
console.log(user.name)
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### `getDocs(schema, ...constraints)` — getDocs equivalent
|
|
175
|
+
Returns `Array<{ id, ...data }>`. All Firestore query constraints work.
|
|
176
|
+
|
|
177
|
+
```js
|
|
178
|
+
import { where, orderBy, limit } from 'firebase/firestore'
|
|
179
|
+
|
|
180
|
+
const users = await getDocs(usersSchema)
|
|
181
|
+
const admins = await getDocs(usersSchema, where('role', '==', 'admin'))
|
|
182
|
+
const recent = await getDocs(usersSchema, orderBy('createdAt', 'desc'), limit(10))
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### `getDocRef(schema, id)` — get a DocumentReference
|
|
186
|
+
Useful for storing references in other documents.
|
|
187
|
+
|
|
188
|
+
```js
|
|
189
|
+
const authorRef = getDocRef(usersSchema, 'user-123')
|
|
190
|
+
await createDoc(postsSchema, { title: 'Hello', authorRef, ... })
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### `onDocSnapshot(schema, id, callback, onError?)` — real-time single doc
|
|
194
|
+
|
|
195
|
+
```js
|
|
196
|
+
const unsub = onDocSnapshot(usersSchema, 'user-123', (user) => {
|
|
197
|
+
if (!user) return
|
|
198
|
+
console.log(user.name)
|
|
199
|
+
})
|
|
200
|
+
unsub() // stop listening
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
### `onCollectionSnapshot(schema, callback, ...constraints)` — real-time collection
|
|
204
|
+
|
|
205
|
+
```js
|
|
206
|
+
const unsub = onCollectionSnapshot(
|
|
207
|
+
usersSchema,
|
|
208
|
+
(users) => console.log(users),
|
|
209
|
+
where('isActive', '==', true),
|
|
210
|
+
orderBy('name')
|
|
211
|
+
)
|
|
212
|
+
unsub()
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
### `createBatch()` — batch writes
|
|
216
|
+
|
|
217
|
+
```js
|
|
218
|
+
const batch = createBatch()
|
|
219
|
+
|
|
220
|
+
const ref = batch.create(usersSchema, { name: 'Bob', email: 'bob@example.com', createdAt: serverTimestamp(), updatedAt: serverTimestamp() })
|
|
221
|
+
|
|
222
|
+
batch
|
|
223
|
+
.set(usersSchema, 'user-456', { name: 'Charlie', email: 'charlie@example.com', createdAt: serverTimestamp(), updatedAt: serverTimestamp() })
|
|
224
|
+
.update(usersSchema, 'user-123', { isActive: false, updatedAt: serverTimestamp() })
|
|
225
|
+
.delete(usersSchema, 'user-old')
|
|
226
|
+
|
|
227
|
+
await batch.commit()
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
### `runTransaction(fn)` — transactions
|
|
231
|
+
|
|
232
|
+
```js
|
|
233
|
+
await runTransaction(async (t) => {
|
|
234
|
+
const post = await t.get(postsSchema, 'post-123')
|
|
235
|
+
if (!post) throw new Error('Post not found')
|
|
236
|
+
|
|
237
|
+
t.update(postsSchema, 'post-123', { viewCount: post.viewCount + 1 })
|
|
238
|
+
t.set(commentsSchema, `c-${Date.now()}`, {
|
|
239
|
+
postRef: getDocRef(postsSchema, 'post-123'),
|
|
240
|
+
body: 'Nice!',
|
|
241
|
+
createdAt: serverTimestamp(),
|
|
242
|
+
})
|
|
243
|
+
})
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
---
|
|
247
|
+
|
|
248
|
+
## Validation errors
|
|
249
|
+
|
|
250
|
+
All validation errors throw a `SchemaValidationError` before any Firestore write happens.
|
|
251
|
+
|
|
252
|
+
```js
|
|
253
|
+
import { SchemaValidationError } from './lib/firebase-schema'
|
|
254
|
+
|
|
255
|
+
try {
|
|
256
|
+
await createDoc(usersSchema, { email: 'not-an-email' })
|
|
257
|
+
} catch (err) {
|
|
258
|
+
if (err instanceof SchemaValidationError) {
|
|
259
|
+
console.log(err.collection) // 'users'
|
|
260
|
+
console.log(err.issues) // Zod issue array
|
|
261
|
+
console.log(err.message)
|
|
262
|
+
// [users] Schema validation failed:
|
|
263
|
+
// • name: Required
|
|
264
|
+
// • email: Invalid email
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
For safe (non-throwing) validation:
|
|
270
|
+
|
|
271
|
+
```js
|
|
272
|
+
const result = usersSchema.safeParse(data)
|
|
273
|
+
if (!result.success) {
|
|
274
|
+
console.error(result.error.message)
|
|
275
|
+
} else {
|
|
276
|
+
console.log(result.data)
|
|
277
|
+
}
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
---
|
|
281
|
+
|
|
282
|
+
## Nested collections (subcollections)
|
|
283
|
+
|
|
284
|
+
Use the Firestore path format for subcollections:
|
|
285
|
+
|
|
286
|
+
```js
|
|
287
|
+
export const commentsSchema = defineCollection('posts/{postId}/comments', (s) => ({
|
|
288
|
+
body: s.string().min(1),
|
|
289
|
+
authorRef: s.reference(),
|
|
290
|
+
createdAt: s.timestamp(),
|
|
291
|
+
}))
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
For subcollections you'll typically use `getDocRef` or `getCollectionRef` with a concrete path:
|
|
295
|
+
|
|
296
|
+
```js
|
|
297
|
+
import { collection, doc } from 'firebase/firestore'
|
|
298
|
+
|
|
299
|
+
// Direct Firestore ref for the specific post's comments
|
|
300
|
+
const commentsRef = collection(db, 'posts', postId, 'comments')
|
|
301
|
+
```
|