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 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
@@ -0,0 +1,3 @@
1
+ export { s } from './fields.js'
2
+ export { defineCollection, SchemaValidationError } from './collection.js'
3
+ export { initFirestoreSchema } from './operations.js'
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
+ ```