@firestore-repository/firebase-js-sdk 0.4.2 → 0.5.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/README.md CHANGED
@@ -4,14 +4,14 @@
4
4
 
5
5
  # firestore-repository
6
6
 
7
- A minimum and universal Firestore ORM (Repository Pattern) for TypeScript
7
+ A minimal and universal Firestore client (Repository Pattern) for TypeScript
8
8
 
9
9
  ## Features
10
10
 
11
- - 🚀 **Minimum**: Only a few straightforward interfaces and classes. You can easily start to use it immediately without learning a lot of things.
11
+ - 🚀 **Minimal**: Only a few straightforward interfaces and classes. You can start using it immediately without a steep learning curve.
12
12
  - 🌐 **Universal**: You can share most code, including schema and query definitions, between backend and frontend.
13
- - 🤝 **Unopinionated**: This library does not introduce any additional concepts, and respects vocabulary of the official Firestore client library.
14
- - ✅ **Type-safe**: This library provides the type-safe interface. It also covers the untyped parts of the official Firestore library.
13
+ - 🤝 **Unopinionated**: This library does not introduce any additional concepts, and respects the vocabulary of the official Firestore client library.
14
+ - ✅ **Type-safe**: This library provides a type-safe interface. It also covers the untyped parts of the official Firestore library.
15
15
  - 🗄️ **Repository Pattern**: A simple and consistent way to access Firestore data.
16
16
 
17
17
  ## Installation
@@ -19,8 +19,8 @@ A minimum and universal Firestore ORM (Repository Pattern) for TypeScript
19
19
  ### For backend (with [`@google-cloud/firestore`](https://www.npmjs.com/package/@google-cloud/firestore))
20
20
 
21
21
  ```shell
22
- npm install firestore-repository @firestore-repository/google-cloud-firestore
23
- ````
22
+ npm install firestore-repository @firestore-repository/google-cloud-firestore
23
+ ```
24
24
 
25
25
  ### For web frontend (with [`@firebase/firestore`](https://www.npmjs.com/package/@firebase/firestore))
26
26
 
@@ -33,79 +33,91 @@ npm install firestore-repository @firestore-repository/firebase-js-sdk
33
33
  ### Define a collection and its repository
34
34
 
35
35
  ```ts
36
- import { mapTo, data, rootCollection } from 'firestore-repository/schema';
36
+ import {
37
+ rootCollection,
38
+ string,
39
+ double,
40
+ map,
41
+ optional,
42
+ literal,
43
+ array,
44
+ } from 'firestore-repository/schema';
37
45
 
38
46
  // For backend
39
47
  import { Firestore } from '@google-cloud/firestore';
40
- import { Repository } from '@firestore-repository/google-cloud-firestore';
48
+ import { rootCollectionRepository } from '@firestore-repository/google-cloud-firestore';
41
49
  const db = new Firestore();
42
50
 
43
51
  // For web frontend
44
52
  import { getFirestore } from '@firebase/firestore';
45
- import { Repository } from '@firestore-repository/firebase-js-sdk';
53
+ import { rootCollectionRepository } from '@firestore-repository/firebase-js-sdk';
46
54
  const db = getFirestore();
47
55
 
48
56
  // define a collection
49
57
  const users = rootCollection({
50
58
  name: 'Users',
51
- id: mapTo('userId'),
52
- data: data<{
53
- name: string;
54
- profile: {
55
- age: number;
56
- gender?: 'male' | 'female';
57
- };
58
- tag: string[];
59
- }>(),
59
+ schema: {
60
+ name: string(),
61
+ profile: map({ age: double(), gender: optional(literal('male', 'female')) }),
62
+ tag: array(string()),
63
+ },
60
64
  });
61
65
 
62
- const repository = new Repository(users, db);
66
+ const repository = rootCollectionRepository(db, users);
63
67
  ```
64
68
 
65
69
  ### Basic operations for a single document
66
70
 
71
+ All operations are **type-safe** based on the schema you defined. The `data` field is typed according to your schema, so invalid data structures are caught at compile time.
72
+
67
73
  ```ts
68
74
  // Set a document
69
75
  await repository.set({
70
- userId: 'user1',
71
- name: 'John Doe',
72
- profile: {
73
- age: 42,
74
- gender: 'male',
75
- },
76
- tag: ['new'],
76
+ ref: 'user1',
77
+ data: { name: 'John Doe', profile: { age: 42, gender: 'male' }, tag: ['new'] },
78
+ });
79
+
80
+ // Create a document (backend only)
81
+ await repository.create({
82
+ ref: 'user2',
83
+ data: { name: 'Charlie', profile: { age: 25, gender: 'male' }, tag: [] },
77
84
  });
78
85
 
79
86
  // Get a document
80
- const doc = await repository.get({ userId: 'user1' });
87
+ const doc = await repository.get('user1');
81
88
 
82
- // Listen a document
83
- repository.getOnSnapshot({ userId: 'user1' }, (doc) => {
89
+ // Listen to a document
90
+ repository.getOnSnapshot('user1', (doc) => {
84
91
  console.log(doc);
85
92
  });
86
93
 
87
94
  // Delete a document
88
- await repository.delete({ userId: 'user2' });
95
+ await repository.delete('user2');
89
96
  ```
90
97
 
91
98
  ### Query
92
99
 
100
+ Field paths in query conditions are **automatically derived from the schema type**, not just plain strings — so typos and invalid paths are caught at compile time. The filter value is also **type-checked based on the field type and operator** (e.g., `array-contains` expects an element type of the array field).
101
+
93
102
  ```ts
94
- import { condition as $, limit, query } from 'firestore-repository/query';
103
+ import { eq, gte, limit, query, where } from 'firestore-repository/query';
104
+ import { average, count, sum } from 'firestore-repository/aggregate';
95
105
 
96
106
  // Define a query
107
+ // Field paths like 'profile.age' are auto-completed and type-checked against the schema.
108
+ // The value `20` is validated as `number` because `profile.age` is `number`.
97
109
  const q = query(
98
- users,
99
- $('profile.age', '>=', 20),
100
- $('profile.gender', '==', 'male'),
101
- limit(10),
110
+ { collection: users },
111
+ where(gte('profile.age', 20), eq('profile.gender', 'male')),
112
+ // where(gte('profile.age', 'foo')) // ← Compile error: string is not assignable to number
113
+ // where(eq('nonExistent', 1)) // ← Compile error: invalid field path
114
+ limit(10),
102
115
  );
103
116
 
104
117
  // List documents
105
118
  const docs = await repository.list(q);
106
- console.log(docs);
107
119
 
108
- // Listen documents
120
+ // Listen to documents
109
121
  repository.listOnSnapshot(q, (docs) => {
110
122
  console.log(docs);
111
123
  });
@@ -123,52 +135,39 @@ console.log(`avg:${result.avgAge} sum:${result.sumAge} count:${result.count}`);
123
135
 
124
136
  ```ts
125
137
  // Get multiple documents (backend only)
126
- const users = await repository.batchGet([{ userId: 'user1' }, { userId: 'user2' }]);
138
+ const users = await repository.batchGet(['user1', 'user2']);
127
139
 
128
140
  // Set multiple documents
129
141
  await repository.batchSet([
130
- {
131
- userId: 'user1',
132
- name: 'Alice',
133
- profile: { age: 30, gender: 'female' },
134
- tag: ['new'],
135
- },
136
- {
137
- userId: 'user2',
138
- name: 'Bob',
139
- profile: { age: 20, gender: 'male' },
140
- tag: [],
141
- },
142
+ { ref: 'user1', data: { name: 'Alice', profile: { age: 30, gender: 'female' }, tag: ['new'] } },
143
+ { ref: 'user2', data: { name: 'Bob', profile: { age: 20, gender: 'male' }, tag: [] } },
142
144
  ]);
143
145
 
144
146
  // Delete multiple documents
145
- await repository.batchDelete([{ userId: 'user1' }, { userId: 'user2' }]);
147
+ await repository.batchDelete(['user1', 'user2']);
146
148
  ```
147
149
 
148
150
  #### Include multiple different operations in a batch
149
151
 
150
152
  ```ts
151
153
  // For backend
152
- const batch = db.writeBatch();
154
+ const batch = db.batch();
153
155
  // For web frontend
154
156
  import { writeBatch } from '@firebase/firestore';
155
- const batch = writeBatch();
157
+ const batch = writeBatch(db);
156
158
 
157
159
  await repository.set(
158
- {
159
- userId: 'user3',
160
- name: 'Bob',
161
- profile: { age: 20, gender: 'male' },
162
- tag: [],
163
- },
164
- { tx: batch },
160
+ { ref: 'user3', data: { name: 'Bob', profile: { age: 20, gender: 'male' }, tag: [] } },
161
+ { tx: batch },
165
162
  );
166
- await repository.batchSet([ /* ... */ ], { tx: batch },
163
+ await repository.batchSet(
164
+ [
165
+ /* ... */
166
+ ],
167
+ { tx: batch },
167
168
  );
168
- await repository.delete({ userId: 'user4' }, { tx: batch });
169
- await repository.batchDelete([{ userId: 'user5' }, { userId: 'user6' }], {
170
- tx: batch,
171
- });
169
+ await repository.delete('user4', { tx: batch });
170
+ await repository.batchDelete(['user5', 'user6'], { tx: batch });
172
171
 
173
172
  await batch.commit();
174
173
  ```
@@ -179,24 +178,105 @@ await batch.commit();
179
178
  // For web frontend
180
179
  import { runTransaction } from '@firebase/firestore';
181
180
 
182
- // Or, please use db.runTransaction for backend
183
- await runTransaction(async (tx) => {
181
+ // Or use db.runTransaction for backend
182
+ await runTransaction(db, async (tx) => {
184
183
  // Get
185
- const doc = await repository.get({ userId: 'user1' }, { tx });
186
-
184
+ const doc = await repository.get('user1', { tx });
185
+
187
186
  if (doc) {
188
- doc.tag = [...doc.tag, 'new-tag'];
187
+ doc.data.tag = [...doc.data.tag, 'new-tag'];
189
188
  // Set
190
189
  await repository.set(doc, { tx });
191
- await repository.batchSet([
192
- { ...doc, userId: 'user2' },
193
- { ...doc, userId: 'user3' },
194
- ], { tx });
190
+ await repository.batchSet(
191
+ [
192
+ { ...doc, ref: 'user2' },
193
+ { ...doc, ref: 'user3' },
194
+ ],
195
+ { tx },
196
+ );
195
197
  }
196
198
 
197
199
  // Delete
198
- await repository.delete({ userId: 'user4' }, { tx });
199
- await repository.batchDelete([{ userId: 'user5' }, { userId: 'user6' }], { tx });
200
+ await repository.delete('user4', { tx });
201
+ await repository.batchDelete(['user5', 'user6'], { tx });
202
+ });
203
+ ```
204
+
205
+ ### Subcollection
206
+
207
+ Subcollections are defined with `subCollection`, specifying the parent collection path. The only difference from root collections is that the document ref becomes a tuple (array of parent doc ID + doc ID). All other operations (query, batch, transaction, etc.) work the same.
208
+
209
+ ```ts
210
+ import { subCollection, string } from 'firestore-repository/schema';
211
+
212
+ // For backend
213
+ import { subcollectionRepository } from '@firestore-repository/google-cloud-firestore';
214
+
215
+ // For web frontend
216
+ import { subcollectionRepository } from '@firestore-repository/firebase-js-sdk';
217
+
218
+ const posts = subCollection({
219
+ name: 'Posts',
220
+ schema: { title: string() },
221
+ parent: ['Users'] as const,
200
222
  });
223
+
224
+ const postRepository = subcollectionRepository(db, posts);
225
+
226
+ // Set a document (ref is [parentDocId, docId])
227
+ await postRepository.set({ ref: ['user1', 'post1'], data: { title: 'My first post' } });
228
+
229
+ // Get a document
230
+ const post = await postRepository.get(['user1', 'post1']);
201
231
  ```
202
232
 
233
+ ### Custom Mapper
234
+
235
+ By default, `rootCollectionRepository` returns a repository with `{ ref: string, data: ... }` as its model type. If you want to use your own application model types, you can define a custom `Mapper` and use `repositoryWithMapper` to create a repository that automatically converts between Firestore documents and your models.
236
+
237
+ A `Mapper` consists of three functions:
238
+
239
+ - `toDocRef`: Converts your model's ID to a Firestore document reference
240
+ - `fromFirestore`: Converts a Firestore document to your read model
241
+ - `toFirestore`: Converts your write model to a Firestore document
242
+
243
+ You can also define different types for reading and writing via `AppModel<Id, Read, Write>` (e.g., omitting server-managed fields from the write type).
244
+
245
+ ```ts
246
+ import { type AppModel, type Mapper } from 'firestore-repository/repository';
247
+
248
+ // For backend
249
+ import { repositoryWithMapper } from '@firestore-repository/google-cloud-firestore';
250
+ // For web frontend
251
+ import { repositoryWithMapper } from '@firestore-repository/firebase-js-sdk';
252
+
253
+ // Define your application model type
254
+ type User = {
255
+ id: string;
256
+ name: string;
257
+ profile: { age: number; gender?: 'male' | 'female' };
258
+ tag: string[];
259
+ };
260
+
261
+ // Define a mapper
262
+ const userMapper: Mapper<typeof users, AppModel<string, User, User>> = {
263
+ toDocRef: (id) => [id],
264
+ fromFirestore: (doc) => ({ id: doc.ref[0], ...doc.data }),
265
+ toFirestore: (user) => ({
266
+ ref: [user.id],
267
+ data: { name: user.name, profile: user.profile, tag: user.tag },
268
+ }),
269
+ };
270
+
271
+ const repository = repositoryWithMapper(db, users, userMapper);
272
+
273
+ // Now the repository accepts and returns your custom User type directly
274
+ await repository.set({
275
+ id: 'user1',
276
+ name: 'Alice',
277
+ profile: { age: 30, gender: 'female' },
278
+ tag: ['new'],
279
+ });
280
+ const user: User | undefined = await repository.get('user1');
281
+ await repository.delete('user1');
282
+ ```
@@ -0,0 +1,5 @@
1
+ import { Firestore } from '@firebase/firestore';
2
+ import type { DocumentSchema } from 'firestore-repository/schema';
3
+ import * as z from 'zod';
4
+ export declare function buildDecodeSchema(schema: DocumentSchema): z.ZodObject<z.ZodRawShape>;
5
+ export declare function buildEncodeSchema(schema: DocumentSchema, db: Firestore): z.ZodObject<z.ZodRawShape>;
@@ -0,0 +1,176 @@
1
+ import { Bytes as FirestoreBytes, DocumentReference as FirestoreDocumentReference, GeoPoint as FirestoreGeoPoint, Timestamp as FirestoreTimestamp, VectorValue as FirestoreVectorValue, arrayRemove as firestoreArrayRemove, arrayUnion as firestoreArrayUnion, doc, increment as firestoreIncrement, serverTimestamp as firestoreServerTimestamp, vector, } from '@firebase/firestore';
2
+ import { documentPath } from 'firestore-repository/path';
3
+ import { _optional, serverOperation } from 'firestore-repository/schema';
4
+ import { assertNever } from 'firestore-repository/util';
5
+ import * as z from 'zod';
6
+ const isServerOp = (v, op) => v != null && typeof v === 'object' && Reflect.get(v, serverOperation) === op;
7
+ export function buildDecodeSchema(schema) {
8
+ return z.object(Object.fromEntries(Object.entries(schema).map(([k, v]) => {
9
+ const s = buildDecodeField(v);
10
+ return [k, v[_optional] ? s.optional() : s];
11
+ })));
12
+ }
13
+ function buildDecodeField(fieldType) {
14
+ switch (fieldType.type) {
15
+ case 'string':
16
+ return z.string();
17
+ case 'bool':
18
+ return z.boolean();
19
+ case 'int64':
20
+ case 'double':
21
+ return z.number();
22
+ case 'null':
23
+ return z.null();
24
+ case 'bytes':
25
+ // oxlint-disable-next-line typescript/no-explicit-any
26
+ return z.custom((v) => v instanceof FirestoreBytes).transform((b) => b.toUint8Array());
27
+ case 'timestamp':
28
+ // oxlint-disable-next-line typescript/no-explicit-any
29
+ return z.custom((v) => v instanceof FirestoreTimestamp).transform((ts) => ts.toDate());
30
+ case 'geoPoint':
31
+ // oxlint-disable-next-line typescript/no-explicit-any
32
+ return z
33
+ .custom((v) => v instanceof FirestoreGeoPoint)
34
+ .transform((gp) => ({ latitude: gp.latitude, longitude: gp.longitude }));
35
+ case 'vector':
36
+ // oxlint-disable-next-line typescript/no-explicit-any
37
+ return z
38
+ .custom((v) => v instanceof FirestoreVectorValue)
39
+ .transform((vv) => vv.toArray());
40
+ case 'docRef':
41
+ // oxlint-disable-next-line typescript/no-explicit-any
42
+ return z
43
+ .custom((v) => v instanceof FirestoreDocumentReference)
44
+ .transform((ref) => {
45
+ const ids = [];
46
+ let current = ref;
47
+ while (current != null) {
48
+ ids.push(current.id);
49
+ current = current.parent.parent;
50
+ }
51
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion
52
+ return ids.reverse();
53
+ });
54
+ case 'map': {
55
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion
56
+ const ft = fieldType;
57
+ return z.object(Object.fromEntries(Object.entries(ft.fields).map(([k, v]) => {
58
+ const s = buildDecodeField(v);
59
+ return [k, v[_optional] ? s.optional() : s];
60
+ })));
61
+ }
62
+ case 'array': {
63
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion
64
+ const ft = fieldType;
65
+ return z.array(buildDecodeField(ft.dynamicPart));
66
+ }
67
+ case 'union': {
68
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion
69
+ const ft = fieldType;
70
+ return zodUnion(ft.elements.map(buildDecodeField));
71
+ }
72
+ case 'const': {
73
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion
74
+ const ft = fieldType;
75
+ return zodUnion(ft.values.map((v) => z.literal(v)));
76
+ }
77
+ default:
78
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion
79
+ return assertNever(fieldType);
80
+ }
81
+ }
82
+ export function buildEncodeSchema(schema, db) {
83
+ return z.object(Object.fromEntries(Object.entries(schema).map(([k, v]) => {
84
+ const s = buildEncodeField(v, db);
85
+ return [k, v[_optional] ? s.optional() : s];
86
+ })));
87
+ }
88
+ function buildEncodeField(fieldType, db) {
89
+ switch (fieldType.type) {
90
+ case 'string':
91
+ return z.string();
92
+ case 'bool':
93
+ return z.boolean();
94
+ case 'null':
95
+ return z.null();
96
+ case 'bytes':
97
+ return z.instanceof(Uint8Array).transform((b) => FirestoreBytes.fromUint8Array(b));
98
+ case 'geoPoint':
99
+ return z
100
+ .object({ latitude: z.number(), longitude: z.number() })
101
+ .transform((gp) => new FirestoreGeoPoint(gp.latitude, gp.longitude));
102
+ case 'vector':
103
+ return z.array(z.number()).transform((arr) => vector(arr));
104
+ case 'int64':
105
+ case 'double':
106
+ return zodUnion([
107
+ // oxlint-disable-next-line typescript/no-explicit-any
108
+ z
109
+ .custom((v) => isServerOp(v, 'increment'))
110
+ .transform((v) => firestoreIncrement(v.amount)),
111
+ z.number(),
112
+ ]);
113
+ case 'timestamp':
114
+ return zodUnion([
115
+ // oxlint-disable-next-line typescript/no-explicit-any
116
+ z
117
+ .custom((v) => isServerOp(v, 'serverTimestamp'))
118
+ .transform(() => firestoreServerTimestamp()),
119
+ z.date().transform((d) => FirestoreTimestamp.fromDate(d)),
120
+ ]);
121
+ case 'docRef': {
122
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion
123
+ const ft = fieldType;
124
+ return z.array(z.string()).transform((ref) =>
125
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion
126
+ doc(db, documentPath(ft.collection, ref)));
127
+ }
128
+ case 'map': {
129
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion
130
+ const ft = fieldType;
131
+ return z.object(Object.fromEntries(Object.entries(ft.fields).map(([k, v]) => {
132
+ const s = buildEncodeField(v, db);
133
+ return [k, v[_optional] ? s.optional() : s];
134
+ })));
135
+ }
136
+ case 'array': {
137
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion
138
+ const ft = fieldType;
139
+ return zodUnion([
140
+ // oxlint-disable-next-line typescript/no-explicit-any
141
+ z
142
+ .custom((v) => isServerOp(v, 'arrayRemove'))
143
+ .transform((v) => firestoreArrayRemove(...v.values)),
144
+ // oxlint-disable-next-line typescript/no-explicit-any
145
+ z
146
+ .custom((v) => isServerOp(v, 'arrayUnion'))
147
+ .transform((v) => firestoreArrayUnion(...v.values)),
148
+ z.array(buildEncodeField(ft.dynamicPart, db)),
149
+ ]);
150
+ }
151
+ case 'union': {
152
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion
153
+ const ft = fieldType;
154
+ return zodUnion(ft.elements.map((e) => buildEncodeField(e, db)));
155
+ }
156
+ case 'const': {
157
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion
158
+ const ft = fieldType;
159
+ return zodUnion(ft.values.map((v) => z.literal(v)));
160
+ }
161
+ default:
162
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion
163
+ return assertNever(fieldType);
164
+ }
165
+ }
166
+ function zodUnion(schemas) {
167
+ if (schemas.length === 0) {
168
+ throw new Error('union must have at least one element');
169
+ }
170
+ if (schemas.length === 1) {
171
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion
172
+ return schemas[0];
173
+ }
174
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion
175
+ return z.union(schemas);
176
+ }
@@ -1,40 +1,16 @@
1
- import { type DocumentReference, type DocumentSnapshot, type Firestore, type Query as FirestoreQuery, Transaction, type WriteBatch } from '@firebase/firestore';
2
- import type { AggregateSpec, Aggregated } from 'firestore-repository/aggregate';
3
- import type { WriteDocumentData } from 'firestore-repository/document';
4
- import type { Query } from 'firestore-repository/query';
5
- import type * as repository from 'firestore-repository/repository';
6
- import { type CollectionSchema, type DocPathElement, type Id, type Model, type ParentId } from 'firestore-repository/schema';
1
+ import type { Firestore, Query as FirestoreQuery, WriteBatch } from '@firebase/firestore';
2
+ import { Transaction } from '@firebase/firestore';
3
+ import { type AppModel, type Mapper, type PlainModel, type Repository, type RootCollectionPlainModel } from 'firestore-repository/repository';
4
+ import type { Collection, RootCollection, SubCollection } from 'firestore-repository/schema';
5
+ /** Platform-specific environment types for Firebase JS SDK */
7
6
  export type Env = {
8
7
  transaction: Transaction;
9
8
  writeBatch: WriteBatch;
10
9
  query: FirestoreQuery;
11
10
  };
12
- export type TransactionOption = repository.TransactionOption<Env>;
13
- export type WriteTransactionOption = repository.WriteTransactionOption<Env>;
14
- export declare class Repository<T extends CollectionSchema = CollectionSchema> implements repository.Repository<T, Env> {
15
- readonly collection: T;
16
- readonly db: Firestore;
17
- constructor(collection: T, db: Firestore);
18
- get(id: Id<T>, options?: TransactionOption): Promise<Model<T> | undefined>;
19
- getOnSnapshot(id: Id<T>, next: (snapshot: Model<T> | undefined) => void, error?: (error: Error) => void, complete?: () => void): repository.Unsubscribe;
20
- list(query: Query<T>): Promise<Model<T>[]>;
21
- listOnSnapshot(query: Query<T>, next: (snapshot: Model<T>[]) => void, error?: (error: Error) => void, complete?: () => void): repository.Unsubscribe;
22
- aggregate<U extends AggregateSpec<T>>(query: Query<T>, spec: U): Promise<Aggregated<U>>;
23
- set(doc: Model<T>, options?: WriteTransactionOption): Promise<void>;
24
- delete(id: Id<T>, options?: WriteTransactionOption): Promise<void>;
25
- batchSet(docs: Model<T>[], options?: WriteTransactionOption): Promise<void>;
26
- batchDelete(ids: Id<T>[], options?: WriteTransactionOption): Promise<void>;
27
- protected batchWriteOperation<U>(targets: U[], runner: {
28
- batch: (batch: WriteBatch, target: U) => void;
29
- transaction: (transaction: Transaction, target: U) => void;
30
- }, options?: WriteTransactionOption): Promise<void>;
31
- protected docRef(id: Id<T>): DocumentReference<import("@firebase/firestore").DocumentData, import("@firebase/firestore").DocumentData>;
32
- protected collectionRef(parentId: ParentId<T>): import("@firebase/firestore").CollectionReference<import("@firebase/firestore").DocumentData, import("@firebase/firestore").DocumentData>;
33
- protected fromFirestore(doc: DocumentSnapshot): Model<T> | undefined;
34
- protected toFirestoreData(data: Model<T>): WriteDocumentData;
35
- }
36
- /**
37
- * Obtain document path elements from DocumentReference
38
- */
39
- export declare const docPathElements: (doc: DocumentReference) => [DocPathElement, ...DocPathElement[]];
40
- export declare const toFirestoreQuery: (db: Firestore, query: Query) => FirestoreQuery;
11
+ /** Creates a repository for a root collection using plain document types */
12
+ export declare const rootCollectionRepository: <T extends RootCollection>(db: Firestore, collection: T) => Repository<T, RootCollectionPlainModel<T>, Env>;
13
+ /** Creates a repository for a subcollection using plain document types */
14
+ export declare const subcollectionRepository: <T extends SubCollection>(db: Firestore, collection: T) => Repository<T, PlainModel<T>, Env>;
15
+ /** Creates a repository with a custom mapper for transforming between Firestore documents and application models */
16
+ export declare const repositoryWithMapper: <T extends Collection, Model extends AppModel>(db: Firestore, collection: T, mapper: Mapper<T, Model>) => Repository<T, Model, Env>;
@@ -1,214 +1,232 @@
1
- import { QueryCompositeFilterConstraint, Transaction, and, average, collection, collectionGroup, count, deleteDoc, doc, endAt, endBefore, query as firestoreQuery, getAggregateFromServer, getDoc, getDocs, limit, limitToLast, onSnapshot, or, orderBy, setDoc, startAfter, startAt, sum, where, writeBatch, } from '@firebase/firestore';
2
- import { collectionPath, docPath, } from 'firestore-repository/schema';
1
+ import { and, average, collection, collectionGroup, count, deleteDoc, doc, endAt, endBefore, getAggregateFromServer, getDoc, getDocs, limit, limitToLast, onSnapshot, or, orderBy, query as firestoreQuery, setDoc, startAfter, startAt, sum, Transaction, where, writeBatch, } from '@firebase/firestore';
2
+ import { collectionPath, documentPath } from 'firestore-repository/path';
3
+ import { plainMapper, rootCollectionPlainMapper, } from 'firestore-repository/repository';
3
4
  import { assertNever } from 'firestore-repository/util';
4
- export class Repository {
5
- collection;
6
- db;
7
- constructor(collection, db) {
8
- this.collection = collection;
9
- this.db = db;
10
- }
11
- async get(id, options) {
12
- const doc = await (options?.tx ? options.tx.get(this.docRef(id)) : getDoc(this.docRef(id)));
13
- return this.fromFirestore(doc);
14
- }
15
- getOnSnapshot(id, next, error, complete) {
16
- return onSnapshot(this.docRef(id), {
17
- next: (doc) => {
18
- next(this.fromFirestore(doc));
19
- },
20
- error: (e) => error?.(e),
21
- complete: () => {
22
- complete?.();
23
- },
24
- });
25
- }
26
- async list(query) {
27
- const { docs } = await getDocs(toFirestoreQuery(this.db, query));
28
- return docs.map((doc) =>
29
- // biome-ignore lint/style/noNonNullAssertion: query result item should not be null
30
- this.fromFirestore(doc));
31
- }
32
- listOnSnapshot(query, next, error, complete) {
33
- return onSnapshot(toFirestoreQuery(this.db, query), {
34
- next: ({ docs }) => {
35
- // biome-ignore lint/style/noNonNullAssertion: query result item should not be null
36
- next(docs.map((doc) => this.fromFirestore(doc)));
37
- },
38
- error: (e) => error?.(e),
39
- complete: () => {
40
- complete?.();
41
- },
42
- });
43
- }
44
- async aggregate(query, spec) {
45
- const aggregateSpec = {};
46
- for (const [k, v] of Object.entries(spec)) {
47
- switch (v.kind) {
48
- case 'count':
49
- aggregateSpec[k] = count();
50
- break;
51
- case 'sum':
52
- aggregateSpec[k] = sum(v.path);
53
- break;
54
- case 'average':
55
- aggregateSpec[k] = average(v.path);
56
- break;
5
+ import { buildDecodeSchema, buildEncodeSchema } from './codec.js';
6
+ /** Creates a repository for a root collection using plain document types */
7
+ export const rootCollectionRepository = (db, collection) => repositoryWithMapper(db, collection, rootCollectionPlainMapper(collection));
8
+ /** Creates a repository for a subcollection using plain document types */
9
+ export const subcollectionRepository = (db, collection) => repositoryWithMapper(db, collection, plainMapper(collection));
10
+ /** Creates a repository with a custom mapper for transforming between Firestore documents and application models */
11
+ export const repositoryWithMapper = (db, collection, mapper) => {
12
+ const { toFirestore, fromFirestore, batchWriteOperation, encodeSchema } = buildFirestoreUtilities(db, collection);
13
+ // oxlint-disable-next-line typescript/no-explicit-any -- Zod output is passed to Firestore SDK
14
+ const encode = (data) => encodeSchema.parse(data);
15
+ return {
16
+ collection,
17
+ get: async (ref, options) => {
18
+ const docRef = toFirestore.docRef(mapper.toDocRef(ref));
19
+ const documentSnapshot = await (options?.tx ? options.tx.get(docRef) : getDoc(docRef));
20
+ const doc = fromFirestore.document(documentSnapshot);
21
+ if (!doc) {
22
+ return undefined;
23
+ }
24
+ return mapper.fromFirestore(doc);
25
+ },
26
+ getOnSnapshot: (ref, next, error) => {
27
+ const docRef = toFirestore.docRef(mapper.toDocRef(ref));
28
+ return onSnapshot(docRef, {
29
+ next: (snapshot) => {
30
+ const doc = fromFirestore.document(snapshot);
31
+ next(doc ? mapper.fromFirestore(doc) : undefined);
32
+ },
33
+ error: (e) => error?.(e),
34
+ });
35
+ },
36
+ list: async (query) => {
37
+ const firestoreQueryObj = toFirestore.query(query);
38
+ const { docs } = await getDocs(firestoreQueryObj);
39
+ return docs.values().map((doc) => mapper.fromFirestore(fromFirestore.documentMustExist(doc)));
40
+ },
41
+ listOnSnapshot: (query, next, error) => {
42
+ const firestoreQueryObj = toFirestore.query(query);
43
+ return onSnapshot(firestoreQueryObj, {
44
+ next: ({ docs }) => next(docs.map((doc) => mapper.fromFirestore(fromFirestore.documentMustExist(doc)))),
45
+ error: (e) => error?.(e),
46
+ });
47
+ },
48
+ aggregate: async (query, spec) => {
49
+ const aggregateSpec = {};
50
+ for (const [k, v] of Object.entries(spec)) {
51
+ switch (v.kind) {
52
+ case 'count':
53
+ aggregateSpec[k] = count();
54
+ break;
55
+ case 'sum':
56
+ aggregateSpec[k] = sum(v.path);
57
+ break;
58
+ case 'average':
59
+ aggregateSpec[k] = average(v.path);
60
+ break;
61
+ default:
62
+ return assertNever(v);
63
+ }
64
+ }
65
+ const firestoreQueryObj = toFirestore.query(query);
66
+ const res = await getAggregateFromServer(firestoreQueryObj, aggregateSpec);
67
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- there is no way to infer correct type
68
+ return res.data();
69
+ },
70
+ set: async (model, options) => {
71
+ const docToWrite = mapper.toFirestore(model);
72
+ const docRef = toFirestore.docRef(docToWrite.ref);
73
+ const data = encode(docToWrite.data);
74
+ await (options?.tx
75
+ ? options.tx instanceof Transaction
76
+ ? options.tx.set(docRef, data)
77
+ : options.tx.set(docRef, data)
78
+ : setDoc(docRef, data));
79
+ },
80
+ delete: async (ref, options) => {
81
+ const docRef = toFirestore.docRef(mapper.toDocRef(ref));
82
+ await (options?.tx ? options.tx.delete(docRef) : deleteDoc(docRef));
83
+ },
84
+ batchSet: async (models, options) => {
85
+ const docs = models.map((m) => {
86
+ const d = mapper.toFirestore(m);
87
+ return { ref: d.ref, data: encode(d.data) };
88
+ });
89
+ await batchWriteOperation(docs, {
90
+ batch: (batch, d) => batch.set(toFirestore.docRef(d.ref), d.data),
91
+ transaction: (tx, d) => tx.set(toFirestore.docRef(d.ref), d.data),
92
+ }, options);
93
+ },
94
+ batchDelete: async (refs, options) => {
95
+ const docRefs = refs.map(mapper.toDocRef);
96
+ await batchWriteOperation(docRefs, {
97
+ batch: (batch, ref) => batch.delete(toFirestore.docRef(ref)),
98
+ transaction: (tx, ref) => tx.delete(toFirestore.docRef(ref)),
99
+ }, options);
100
+ },
101
+ };
102
+ };
103
+ const buildFirestoreUtilities = (db, coll) => {
104
+ const decodeSchema = buildDecodeSchema(coll.schema);
105
+ const encodeSchema = buildEncodeSchema(coll.schema, db);
106
+ const toFirestore = {
107
+ docRef: (ref) => doc(db, documentPath(coll, ref)),
108
+ query: (query) => {
109
+ let base;
110
+ if ('collection' in query.base) {
111
+ base = query.base.group
112
+ ? collectionGroup(db, query.base.collection.name)
113
+ : collection(db, collectionPath(query.base.collection, query.base.parent));
114
+ }
115
+ else if ('extends' in query.base) {
116
+ base = toFirestore.query(query.base.extends);
117
+ }
118
+ else {
119
+ return assertNever(query.base);
120
+ }
121
+ if (!query.constraints || query.constraints.length === 0) {
122
+ return base;
123
+ }
124
+ const { filter, nonFilter } = query.constraints.reduce((acc, constraint) => {
125
+ switch (constraint.kind) {
126
+ case 'where': {
127
+ const f = toFirestore.filter(constraint.condition);
128
+ acc.filter = acc.filter ? and(acc.filter, f) : f;
129
+ break;
130
+ }
131
+ case 'orderBy':
132
+ acc.nonFilter.push(orderBy(constraint.field, constraint.direction));
133
+ break;
134
+ case 'limit':
135
+ acc.nonFilter.push(limit(constraint.limit));
136
+ break;
137
+ case 'limitToLast':
138
+ acc.nonFilter.push(limitToLast(constraint.limit));
139
+ break;
140
+ case 'offset':
141
+ // https://github.com/firebase/firebase-js-sdk/issues/479
142
+ throw new Error('firebase-js-sdk does not support offset constraint');
143
+ case 'startAt': {
144
+ const { cursor } = constraint;
145
+ acc.nonFilter.push(startAt(...cursor));
146
+ break;
147
+ }
148
+ case 'startAfter': {
149
+ const { cursor } = constraint;
150
+ acc.nonFilter.push(startAfter(...cursor));
151
+ break;
152
+ }
153
+ case 'endAt': {
154
+ const { cursor } = constraint;
155
+ acc.nonFilter.push(endAt(...cursor));
156
+ break;
157
+ }
158
+ case 'endBefore': {
159
+ const { cursor } = constraint;
160
+ acc.nonFilter.push(endBefore(...cursor));
161
+ break;
162
+ }
163
+ default:
164
+ return assertNever(constraint);
165
+ }
166
+ return acc;
167
+ }, { nonFilter: [] });
168
+ // Wrap single filter in and() to satisfy QueryCompositeFilterConstraint overload
169
+ return filter
170
+ ? firestoreQuery(base, and(filter), ...nonFilter)
171
+ : firestoreQuery(base, ...nonFilter);
172
+ },
173
+ filter: (expr) => {
174
+ switch (expr.kind) {
175
+ case 'fieldValueCondition':
176
+ return where(expr.fieldPath, expr.opStr, expr.value);
177
+ case 'and':
178
+ return and(...expr.filters.map(toFirestore.filter));
179
+ case 'or':
180
+ return or(...expr.filters.map(toFirestore.filter));
57
181
  default:
58
- return assertNever(v);
182
+ return assertNever(expr);
59
183
  }
60
- }
61
- const res = await getAggregateFromServer(toFirestoreQuery(this.db, query), aggregateSpec);
62
- return res.data();
63
- }
64
- async set(doc, options) {
65
- const data = this.toFirestoreData(doc);
66
- await (options?.tx
67
- ? options.tx instanceof Transaction
68
- ? options.tx.set(this.docRef(doc), data)
69
- : options.tx.set(this.docRef(doc), data)
70
- : setDoc(this.docRef(doc), data));
71
- }
72
- async delete(id, options) {
73
- await (options?.tx ? options.tx.delete(this.docRef(id)) : deleteDoc(this.docRef(id)));
74
- }
75
- async batchSet(docs, options) {
76
- await this.batchWriteOperation(docs, {
77
- batch: (batch, doc) => batch.set(this.docRef(doc), this.toFirestoreData(doc)),
78
- transaction: (tx, doc) => tx.set(this.docRef(doc), this.toFirestoreData(doc)),
79
- }, options);
80
- }
81
- async batchDelete(ids, options) {
82
- await this.batchWriteOperation(ids, {
83
- batch: (batch, id) => batch.delete(this.docRef(id)),
84
- transaction: (tx, id) => tx.delete(this.docRef(id)),
85
- }, options);
86
- }
87
- async batchWriteOperation(targets, runner, options) {
184
+ },
185
+ };
186
+ const fromFirestore = {
187
+ documentMustExist: (document) => {
188
+ const data = document.data();
189
+ if (!data) {
190
+ throw new Error('document must exist');
191
+ }
192
+ return {
193
+ ref: fromFirestore.docRef(document.ref),
194
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- Zod output is typed by schema
195
+ data: decodeSchema.parse(data),
196
+ };
197
+ },
198
+ document: (document) => {
199
+ if (!document.exists()) {
200
+ return undefined;
201
+ }
202
+ return fromFirestore.documentMustExist(document);
203
+ },
204
+ docRef: (ref) => {
205
+ const docRef = [];
206
+ let currentRef = ref;
207
+ while (currentRef != null) {
208
+ docRef.push(currentRef.id);
209
+ currentRef = currentRef.parent.parent;
210
+ }
211
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- cannot infer type here
212
+ return docRef.reverse();
213
+ },
214
+ };
215
+ const batchWriteOperation = async (targets, runner, options) => {
88
216
  const tx = options?.tx;
89
217
  if (tx) {
90
218
  if (tx instanceof Transaction) {
91
- targets.forEach((target) => runner.transaction(tx, target));
219
+ targets.forEach((target) => void runner.transaction(tx, target));
92
220
  }
93
221
  else {
94
- targets.forEach((target) => runner.batch(tx, target));
222
+ targets.forEach((target) => void runner.batch(tx, target));
95
223
  }
96
224
  }
97
225
  else {
98
- const batch = writeBatch(this.db);
99
- targets.forEach((target) => runner.batch(batch, target));
226
+ const batch = writeBatch(db);
227
+ targets.forEach((target) => void runner.batch(batch, target));
100
228
  await batch.commit();
101
229
  }
102
- }
103
- docRef(id) {
104
- return doc(this.db, docPath(this.collection, id));
105
- }
106
- collectionRef(parentId) {
107
- return collection(this.db, collectionPath(this.collection, parentId));
108
- }
109
- fromFirestore(doc) {
110
- const data = doc.data();
111
- const [id, ...parentPath] = docPathElements(doc.ref);
112
- return data
113
- ? {
114
- ...this.collection.data.from(data),
115
- ...this.collection.collectionPath.from(parentPath),
116
- ...this.collection.id.from(id.id),
117
- }
118
- : undefined;
119
- }
120
- toFirestoreData(data) {
121
- return this.collection.data.to(data);
122
- }
123
- }
124
- /**
125
- * Obtain document path elements from DocumentReference
126
- */
127
- export const docPathElements = (doc) => {
128
- const parentPath = [];
129
- let cursor = doc.parent.parent;
130
- while (cursor) {
131
- parentPath.push({ id: cursor.id, collection: cursor.parent.id });
132
- cursor = cursor.parent.parent;
133
- }
134
- return [{ collection: doc.parent.id, id: doc.id }, ...parentPath];
135
- };
136
- export const toFirestoreQuery = (db, query) => {
137
- const { filter, nonFilter } = (query.constraints ?? []).reduce((acc, constraint) => {
138
- switch (constraint.kind) {
139
- case 'where':
140
- case 'and':
141
- case 'or': {
142
- const filter = toFirestoreQueryFilterConstraint(constraint);
143
- acc.filter = acc.filter ? and(acc.filter, filter) : filter;
144
- break;
145
- }
146
- case 'orderBy':
147
- acc.nonFilter.push(orderBy(constraint.field, constraint.direction));
148
- break;
149
- case 'limit':
150
- acc.nonFilter.push(limit(constraint.limit));
151
- break;
152
- case 'limitToLast':
153
- acc.nonFilter.push(limitToLast(constraint.limit));
154
- break;
155
- case 'offset':
156
- // https://github.com/firebase/firebase-js-sdk/issues/479
157
- throw new Error('firestore-js-sdk does not support offset constraint');
158
- case 'startAt': {
159
- const { cursor } = constraint;
160
- acc.nonFilter.push(startAt(...cursor));
161
- break;
162
- }
163
- case 'startAfter': {
164
- const { cursor } = constraint;
165
- acc.nonFilter.push(startAfter(...cursor));
166
- break;
167
- }
168
- case 'endAt': {
169
- const { cursor } = constraint;
170
- acc.nonFilter.push(endAt(...cursor));
171
- break;
172
- }
173
- case 'endBefore': {
174
- const { cursor } = constraint;
175
- acc.nonFilter.push(endBefore(...cursor));
176
- break;
177
- }
178
- default:
179
- return assertNever(constraint);
180
- }
181
- return acc;
182
- }, { nonFilter: [] });
183
- let base;
184
- switch (query.base.kind) {
185
- case 'collection':
186
- base = collection(db, collectionPath(query.base.collection, query.base.parentId));
187
- break;
188
- case 'collectionGroup':
189
- base = collectionGroup(db, query.base.collection.name);
190
- break;
191
- case 'extends':
192
- base = toFirestoreQuery(db, query.base.query);
193
- break;
194
- default:
195
- base = assertNever(query.base);
196
- }
197
- return filter
198
- ? filter instanceof QueryCompositeFilterConstraint
199
- ? firestoreQuery(base, filter, ...nonFilter)
200
- : firestoreQuery(base, filter, ...nonFilter)
201
- : firestoreQuery(base, ...nonFilter);
202
- };
203
- const toFirestoreQueryFilterConstraint = (expr) => {
204
- switch (expr.kind) {
205
- case 'where':
206
- return where(expr.fieldPath, expr.opStr, expr.value);
207
- case 'or':
208
- return or(...expr.filters.map(toFirestoreQueryFilterConstraint));
209
- case 'and':
210
- return and(...expr.filters.map(toFirestoreQueryFilterConstraint));
211
- default:
212
- return assertNever(expr);
213
- }
230
+ };
231
+ return { fromFirestore, toFirestore, batchWriteOperation, encodeSchema };
214
232
  };
package/package.json CHANGED
@@ -1,53 +1,58 @@
1
1
  {
2
2
  "name": "@firestore-repository/firebase-js-sdk",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "description": "A minimum and universal Firestore ORM (Repository Pattern) for TypeScript",
5
+ "keywords": [
6
+ "database",
7
+ "firebase",
8
+ "firebase-js-sdk",
9
+ "firestore",
10
+ "orm",
11
+ "repository"
12
+ ],
5
13
  "homepage": "https://github.com/ikenox/firestore-repository",
14
+ "license": "MIT",
15
+ "author": "Naoto Ikeno <ikenox@gmail.com>",
6
16
  "repository": {
7
17
  "type": "git",
8
18
  "url": "https://github.com/ikenox/firestore-repository.git"
9
19
  },
20
+ "files": [
21
+ "build",
22
+ "!**/*.tsbuildinfo"
23
+ ],
10
24
  "type": "module",
11
- "dependencies": {
12
- "@firebase/firestore": "^4.7.5",
13
- "firestore-repository": "0.4.2"
14
- },
15
- "devDependencies": {
16
- "@firebase/app": "^0.10.16"
17
- },
18
25
  "exports": {
19
26
  ".": {
27
+ "@firestore-repository/source": "./src/index.ts",
20
28
  "import": {
21
29
  "types": "./build/esm/index.d.ts",
22
30
  "default": "./build/esm/index.js"
23
31
  }
24
32
  },
25
33
  "./*": {
34
+ "@firestore-repository/source": "./src/*.ts",
26
35
  "import": {
27
36
  "types": "./build/esm/*.d.ts",
28
37
  "default": "./build/esm/*.js"
29
38
  }
30
39
  }
31
40
  },
32
- "files": [
33
- "build",
34
- "!**/*.tsbuildinfo"
35
- ],
36
- "keywords": [
37
- "firestore",
38
- "orm",
39
- "database",
40
- "repository",
41
- "firebase",
42
- "firebase-js-sdk"
43
- ],
44
- "author": "Naoto Ikeno <ikenox@gmail.com>",
45
- "license": "MIT",
46
41
  "publishConfig": {
47
42
  "access": "public"
48
43
  },
44
+ "dependencies": {
45
+ "@firebase/firestore": "^4.12.0",
46
+ "zod": "^4.3.6",
47
+ "firestore-repository": "0.5.0"
48
+ },
49
+ "devDependencies": {
50
+ "@firebase/app": "^0.14.9"
51
+ },
52
+ "engines": {
53
+ "node": ">=18"
54
+ },
49
55
  "scripts": {
50
- "typecheck": "tsc --noEmit",
51
56
  "build": "rm -rf build/ && tsc -b tsconfig.build.json"
52
57
  }
53
58
  }