@firestore-repository/firebase-js-sdk 0.4.1 → 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,13 +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.
12
- - 🌐 **Unopinionated**: This library does not introduce any additional concepts, and respects an interface of the official Firestore client library.
13
- - **Type-safe**: This library provides the type-safe interface. It also covers the untyped parts of the official Firestore library.
11
+ - 🚀 **Minimal**: Only a few straightforward interfaces and classes. You can start using it immediately without a steep learning curve.
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 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.
14
15
  - 🗄️ **Repository Pattern**: A simple and consistent way to access Firestore data.
15
16
 
16
17
  ## Installation
@@ -18,8 +19,8 @@ A minimum and universal Firestore ORM (Repository Pattern) for TypeScript
18
19
  ### For backend (with [`@google-cloud/firestore`](https://www.npmjs.com/package/@google-cloud/firestore))
19
20
 
20
21
  ```shell
21
- npm install firestore-repository @firestore-repository/google-cloud-firestore
22
- ````
22
+ npm install firestore-repository @firestore-repository/google-cloud-firestore
23
+ ```
23
24
 
24
25
  ### For web frontend (with [`@firebase/firestore`](https://www.npmjs.com/package/@firebase/firestore))
25
26
 
@@ -32,86 +33,100 @@ npm install firestore-repository @firestore-repository/firebase-js-sdk
32
33
  ### Define a collection and its repository
33
34
 
34
35
  ```ts
35
- 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';
36
45
 
37
46
  // For backend
38
47
  import { Firestore } from '@google-cloud/firestore';
39
- import { Repository } from '@firestore-repository/google-cloud-firestore';
48
+ import { rootCollectionRepository } from '@firestore-repository/google-cloud-firestore';
40
49
  const db = new Firestore();
41
50
 
42
51
  // For web frontend
43
52
  import { getFirestore } from '@firebase/firestore';
44
- import { Repository } from '@firestore-repository/firebase-js-sdk';
53
+ import { rootCollectionRepository } from '@firestore-repository/firebase-js-sdk';
45
54
  const db = getFirestore();
46
55
 
47
56
  // define a collection
48
57
  const users = rootCollection({
49
58
  name: 'Users',
50
- id: mapTo('userId'),
51
- data: data<{
52
- name: string;
53
- profile: {
54
- age: number;
55
- gender?: 'male' | 'female';
56
- };
57
- tag: string[];
58
- }>(),
59
+ schema: {
60
+ name: string(),
61
+ profile: map({ age: double(), gender: optional(literal('male', 'female')) }),
62
+ tag: array(string()),
63
+ },
59
64
  });
60
65
 
61
- const repository = new Repository(users, db);
66
+ const repository = rootCollectionRepository(db, users);
62
67
  ```
63
68
 
64
69
  ### Basic operations for a single document
65
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
+
66
73
  ```ts
67
74
  // Set a document
68
75
  await repository.set({
69
- userId: 'user1',
70
- name: 'John Doe',
71
- profile: {
72
- age: 42,
73
- gender: 'male',
74
- },
75
- 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: [] },
76
84
  });
77
85
 
78
86
  // Get a document
79
- const doc = await repository.get({ userId: 'user1' });
87
+ const doc = await repository.get('user1');
80
88
 
81
- // Listen a document
82
- repository.getOnSnapshot({ userId: 'user1' }, (doc) => {
89
+ // Listen to a document
90
+ repository.getOnSnapshot('user1', (doc) => {
83
91
  console.log(doc);
84
92
  });
85
93
 
86
94
  // Delete a document
87
- await repository.delete({ userId: 'user2' });
95
+ await repository.delete('user2');
88
96
  ```
89
97
 
90
98
  ### Query
91
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
+
92
102
  ```ts
93
- import { condition as $, limit, query, where } from 'firestore-repository/query';
103
+ import { eq, gte, limit, query, where } from 'firestore-repository/query';
104
+ import { average, count, sum } from 'firestore-repository/aggregate';
94
105
 
95
106
  // Define a query
96
- const query1 = query(users, $('profile.age', '>=', 20), limit(10));
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`.
109
+ const q = query(
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),
115
+ );
97
116
 
98
117
  // List documents
99
- const docs = await repository.list(query1);
100
- console.log(docs);
118
+ const docs = await repository.list(q);
101
119
 
102
- // Listen documents
103
- repository.listOnSnapshot(query1, (docs) => {
120
+ // Listen to documents
121
+ repository.listOnSnapshot(q, (docs) => {
104
122
  console.log(docs);
105
123
  });
106
124
 
107
125
  // Aggregate
108
- const result = await repository.aggregate({
109
- query: query1,
110
- spec: {
111
- avgAge: average('profile.age'),
112
- sumAge: sum('profile.age'),
113
- count: count(),
114
- },
126
+ const result = await repository.aggregate(q, {
127
+ avgAge: average('profile.age'),
128
+ sumAge: sum('profile.age'),
129
+ count: count(),
115
130
  });
116
131
  console.log(`avg:${result.avgAge} sum:${result.sumAge} count:${result.count}`);
117
132
  ```
@@ -120,52 +135,39 @@ console.log(`avg:${result.avgAge} sum:${result.sumAge} count:${result.count}`);
120
135
 
121
136
  ```ts
122
137
  // Get multiple documents (backend only)
123
- const users = await repository.batchGet([{ userId: 'user1' }, { userId: 'user2' }]);
138
+ const users = await repository.batchGet(['user1', 'user2']);
124
139
 
125
140
  // Set multiple documents
126
141
  await repository.batchSet([
127
- {
128
- userId: 'user1',
129
- name: 'Alice',
130
- profile: { age: 30, gender: 'female' },
131
- tag: ['new'],
132
- },
133
- {
134
- userId: 'user2',
135
- name: 'Bob',
136
- profile: { age: 20, gender: 'male' },
137
- tag: [],
138
- },
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: [] } },
139
144
  ]);
140
145
 
141
146
  // Delete multiple documents
142
- await repository.batchDelete([{ userId: 'user1' }, { userId: 'user2' }]);
147
+ await repository.batchDelete(['user1', 'user2']);
143
148
  ```
144
149
 
145
150
  #### Include multiple different operations in a batch
146
151
 
147
152
  ```ts
148
153
  // For backend
149
- const batch = db.writeBatch();
154
+ const batch = db.batch();
150
155
  // For web frontend
151
156
  import { writeBatch } from '@firebase/firestore';
152
- const batch = writeBatch();
157
+ const batch = writeBatch(db);
153
158
 
154
159
  await repository.set(
155
- {
156
- userId: 'user3',
157
- name: 'Bob',
158
- profile: { age: 20, gender: 'male' },
159
- tag: [],
160
- },
161
- { tx: batch },
160
+ { ref: 'user3', data: { name: 'Bob', profile: { age: 20, gender: 'male' }, tag: [] } },
161
+ { tx: batch },
162
162
  );
163
- await repository.batchSet([ /* ... */ ], { tx: batch },
163
+ await repository.batchSet(
164
+ [
165
+ /* ... */
166
+ ],
167
+ { tx: batch },
164
168
  );
165
- await repository.delete({ userId: 'user4' }, { tx: batch });
166
- await repository.batchDelete([{ userId: 'user5' }, { userId: 'user6' }], {
167
- tx: batch,
168
- });
169
+ await repository.delete('user4', { tx: batch });
170
+ await repository.batchDelete(['user5', 'user6'], { tx: batch });
169
171
 
170
172
  await batch.commit();
171
173
  ```
@@ -176,24 +178,105 @@ await batch.commit();
176
178
  // For web frontend
177
179
  import { runTransaction } from '@firebase/firestore';
178
180
 
179
- // Or, please use db.runTransaction for backend
180
- await runTransaction(async (tx) => {
181
+ // Or use db.runTransaction for backend
182
+ await runTransaction(db, async (tx) => {
181
183
  // Get
182
- const doc = await repository.get({ userId: 'user1' }, { tx });
183
-
184
+ const doc = await repository.get('user1', { tx });
185
+
184
186
  if (doc) {
185
- doc.tag = [...doc.tag, 'new-tag'];
187
+ doc.data.tag = [...doc.data.tag, 'new-tag'];
186
188
  // Set
187
189
  await repository.set(doc, { tx });
188
- await repository.batchSet([
189
- { ...doc, userId: 'user2' },
190
- { ...doc, userId: 'user3' },
191
- ]);
190
+ await repository.batchSet(
191
+ [
192
+ { ...doc, ref: 'user2' },
193
+ { ...doc, ref: 'user3' },
194
+ ],
195
+ { tx },
196
+ );
192
197
  }
193
198
 
194
199
  // Delete
195
- await repository.delete({ userId: 'user4' }, { tx });
196
- await repository.batchDelete([{ userId: 'user5' }, { userId: 'user6' }]);
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,
197
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']);
198
231
  ```
199
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.1",
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.1"
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
  }