@firestore-repository/google-cloud-firestore 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 type * as firestore from '@google-cloud/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.Firestore): z.ZodObject<z.ZodRawShape>;
@@ -0,0 +1,173 @@
1
+ import { DocumentReference as FirestoreDocumentReference, FieldValue, GeoPoint as FirestoreGeoPoint, Timestamp as FirestoreTimestamp, VectorValue as FirestoreVectorValue, } from '@google-cloud/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
+ return z.instanceof(Buffer).transform((b) => new Uint8Array(b));
26
+ case 'timestamp':
27
+ return z.instanceof(FirestoreTimestamp).transform((ts) => ts.toDate());
28
+ case 'geoPoint':
29
+ return z
30
+ .instanceof(FirestoreGeoPoint)
31
+ .transform((gp) => ({ latitude: gp.latitude, longitude: gp.longitude }));
32
+ case 'vector':
33
+ // oxlint-disable-next-line typescript/no-explicit-any
34
+ return z
35
+ .custom((v) => v instanceof FirestoreVectorValue)
36
+ .transform((vv) => vv.toArray());
37
+ case 'docRef':
38
+ // oxlint-disable-next-line typescript/no-explicit-any
39
+ return z
40
+ .custom((v) => v instanceof FirestoreDocumentReference)
41
+ .transform((ref) => {
42
+ const ids = [];
43
+ let current = ref;
44
+ while (current != null) {
45
+ ids.push(current.id);
46
+ current = current.parent.parent;
47
+ }
48
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion
49
+ return ids.reverse();
50
+ });
51
+ case 'map': {
52
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion
53
+ const ft = fieldType;
54
+ return z.object(Object.fromEntries(Object.entries(ft.fields).map(([k, v]) => {
55
+ const s = buildDecodeField(v);
56
+ return [k, v[_optional] ? s.optional() : s];
57
+ })));
58
+ }
59
+ case 'array': {
60
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion
61
+ const ft = fieldType;
62
+ return z.array(buildDecodeField(ft.dynamicPart));
63
+ }
64
+ case 'union': {
65
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion
66
+ const ft = fieldType;
67
+ return zodUnion(ft.elements.map(buildDecodeField));
68
+ }
69
+ case 'const': {
70
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion
71
+ const ft = fieldType;
72
+ return zodUnion(ft.values.map((v) => z.literal(v)));
73
+ }
74
+ default:
75
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion
76
+ return assertNever(fieldType);
77
+ }
78
+ }
79
+ export function buildEncodeSchema(schema, db) {
80
+ return z.object(Object.fromEntries(Object.entries(schema).map(([k, v]) => {
81
+ const s = buildEncodeField(v, db);
82
+ return [k, v[_optional] ? s.optional() : s];
83
+ })));
84
+ }
85
+ function buildEncodeField(fieldType, db) {
86
+ switch (fieldType.type) {
87
+ case 'string':
88
+ return z.string();
89
+ case 'bool':
90
+ return z.boolean();
91
+ case 'null':
92
+ return z.null();
93
+ case 'bytes':
94
+ return z.instanceof(Uint8Array).transform((b) => Buffer.from(b));
95
+ case 'geoPoint':
96
+ return z
97
+ .object({ latitude: z.number(), longitude: z.number() })
98
+ .transform((gp) => new FirestoreGeoPoint(gp.latitude, gp.longitude));
99
+ case 'vector':
100
+ return z.array(z.number()).transform((arr) => FieldValue.vector(arr));
101
+ case 'int64':
102
+ case 'double':
103
+ return zodUnion([
104
+ // oxlint-disable-next-line typescript/no-explicit-any
105
+ z
106
+ .custom((v) => isServerOp(v, 'increment'))
107
+ .transform((v) => FieldValue.increment(v.amount)),
108
+ z.number(),
109
+ ]);
110
+ case 'timestamp':
111
+ return zodUnion([
112
+ // oxlint-disable-next-line typescript/no-explicit-any
113
+ z
114
+ .custom((v) => isServerOp(v, 'serverTimestamp'))
115
+ .transform(() => FieldValue.serverTimestamp()),
116
+ z.date().transform((d) => FirestoreTimestamp.fromDate(d)),
117
+ ]);
118
+ case 'docRef': {
119
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion
120
+ const ft = fieldType;
121
+ return z.array(z.string()).transform((ref) =>
122
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion
123
+ db.doc(documentPath(ft.collection, ref)));
124
+ }
125
+ case 'map': {
126
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion
127
+ const ft = fieldType;
128
+ return z.object(Object.fromEntries(Object.entries(ft.fields).map(([k, v]) => {
129
+ const s = buildEncodeField(v, db);
130
+ return [k, v[_optional] ? s.optional() : s];
131
+ })));
132
+ }
133
+ case 'array': {
134
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion
135
+ const ft = fieldType;
136
+ return zodUnion([
137
+ // oxlint-disable-next-line typescript/no-explicit-any
138
+ z
139
+ .custom((v) => isServerOp(v, 'arrayRemove'))
140
+ .transform((v) => FieldValue.arrayRemove(...v.values)),
141
+ // oxlint-disable-next-line typescript/no-explicit-any
142
+ z
143
+ .custom((v) => isServerOp(v, 'arrayUnion'))
144
+ .transform((v) => FieldValue.arrayUnion(...v.values)),
145
+ z.array(buildEncodeField(ft.dynamicPart, db)),
146
+ ]);
147
+ }
148
+ case 'union': {
149
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion
150
+ const ft = fieldType;
151
+ return zodUnion(ft.elements.map((e) => buildEncodeField(e, db)));
152
+ }
153
+ case 'const': {
154
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion
155
+ const ft = fieldType;
156
+ return zodUnion(ft.values.map((v) => z.literal(v)));
157
+ }
158
+ default:
159
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion
160
+ return assertNever(fieldType);
161
+ }
162
+ }
163
+ function zodUnion(schemas) {
164
+ if (schemas.length === 0) {
165
+ throw new Error('union must have at least one element');
166
+ }
167
+ if (schemas.length === 1) {
168
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion
169
+ return schemas[0];
170
+ }
171
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion
172
+ return z.union(schemas);
173
+ }
@@ -1,59 +1,33 @@
1
- import { type DocumentReference, type DocumentSnapshot, Filter, type Firestore, type Query as FirestoreQuery, Transaction, type WriteBatch } from '@google-cloud/firestore';
2
- import type { AggregateSpec, Aggregated } from 'firestore-repository/aggregate';
3
- import type { WriteDocumentData } from 'firestore-repository/document';
4
- import type { FilterExpression, Offset, Query } from 'firestore-repository/query';
5
- import type * as repository from 'firestore-repository/repository';
6
- import { type CollectionSchema, type DocPathElement, type Id, type Model } from 'firestore-repository/schema';
1
+ import type * as firestore from '@google-cloud/firestore';
2
+ import { type AppModel, type Mapper, type PlainModel, type Repository, type RootCollectionPlainModel, type TransactionOption, type WriteTransactionOption } from 'firestore-repository/repository';
3
+ import type { Collection, RootCollection, SubCollection } from 'firestore-repository/schema';
4
+ /** Platform-specific environment types for Google Cloud Firestore */
7
5
  export type Env = {
8
- transaction: Transaction;
9
- writeBatch: WriteBatch;
10
- query: FirestoreQuery;
6
+ transaction: firestore.Transaction;
7
+ writeBatch: firestore.WriteBatch;
8
+ query: firestore.Query;
11
9
  };
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): repository.Unsubscribe;
20
- list(query: Query<T>): Promise<Model<T>[]>;
21
- listOnSnapshot(query: Query<T>, next: (snapshot: Model<T>[]) => void, error?: (error: Error) => void): repository.Unsubscribe;
22
- aggregate<U extends AggregateSpec<T>>(query: Query<T>, spec: U): Promise<Aggregated<U>>;
10
+ /** Extended repository interface for Google Cloud Firestore with additional methods (create, batchCreate, batchGet) */
11
+ export interface GoogleCloudFirestoreRepository<T extends Collection, Model extends AppModel> extends Repository<T, Model, Env> {
23
12
  /**
24
- * Create a new document
13
+ * Creates a new document
25
14
  * @throws If the document already exists
26
15
  */
27
- create(doc: Model<T>, options?: WriteTransactionOption): Promise<void>;
28
- set(doc: Model<T>, options?: WriteTransactionOption): Promise<void>;
29
- delete(id: Id<T>, options?: WriteTransactionOption): Promise<void>;
16
+ create: (docToWrite: Model['write'], options?: WriteTransactionOption<Env>) => Promise<void>;
30
17
  /**
31
- * Get documents by multiple ID
32
- * example: [{id:1},{id:2},{id:5},{id:1}] -> [doc1,doc2,undefined,doc1]
18
+ * Creates multiple documents.
19
+ * The entire operation fails if any creation fails.
33
20
  */
34
- batchGet(ids: Id<T>[], options?: TransactionOption): Promise<(Model<T> | undefined)[]>;
35
- batchSet(docs: Model<T>[], options?: WriteTransactionOption): Promise<void>;
21
+ batchCreate: (docs: Model['write'][], options?: WriteTransactionOption<Env>) => Promise<void>;
36
22
  /**
37
- * Create multiple documents
38
- * The entire operation will fail if one creation fails
23
+ * Gets multiple documents by their IDs.
24
+ * @example [{id:1}, {id:2}, {id:5}, {id:1}] -> [doc1, doc2, undefined, doc1]
39
25
  */
40
- batchCreate(docs: Model<T>[], options?: WriteTransactionOption): Promise<void>;
41
- batchDelete(ids: Id<T>[], options?: WriteTransactionOption): Promise<void>;
42
- protected batchWriteOperation<U>(targets: U[], runner: {
43
- batch: (batch: WriteBatch, target: U) => void;
44
- transaction: (transaction: Transaction, target: U) => void;
45
- }, options?: WriteTransactionOption): Promise<void>;
46
- protected docRef(id: Id<T>): DocumentReference<FirebaseFirestore.DocumentData, FirebaseFirestore.DocumentData>;
47
- protected fromFirestore(doc: DocumentSnapshot): Model<T> | undefined;
48
- protected toFirestoreData(data: Model<T>): WriteDocumentData;
26
+ batchGet: (refs: Model['id'][], options?: TransactionOption<Env>) => Promise<(Model['read'] | undefined)[]>;
49
27
  }
50
- /**
51
- * Obtain document path elements from DocumentReference
52
- */
53
- export declare const docPathElements: (doc: DocumentReference) => [DocPathElement, ...DocPathElement[]];
54
- export declare const toFirestoreQuery: (db: Firestore, query: Query) => FirestoreQuery;
55
- export declare const toFirestoreFilter: (expr: FilterExpression) => Filter;
56
- /**
57
- * A query offset constraint
58
- */
59
- export declare const offset: (offset: number) => Offset;
28
+ /** Creates a repository for a root collection using plain document types */
29
+ export declare const rootCollectionRepository: <T extends RootCollection>(db: firestore.Firestore, collection: T) => Repository<T, RootCollectionPlainModel<T>, Env>;
30
+ /** Creates a repository for a subcollection using plain document types */
31
+ export declare const subcollectionRepository: <T extends SubCollection>(db: firestore.Firestore, collection: T) => Repository<T, PlainModel<T>, Env>;
32
+ /** Creates a repository with a custom mapper for transforming between Firestore documents and application models */
33
+ export declare const repositoryWithMapper: <T extends Collection, Model extends AppModel>(db: firestore.Firestore, collection: T, mapper: Mapper<T, Model>) => GoogleCloudFirestoreRepository<T, Model>;
@@ -1,217 +1,236 @@
1
- import { AggregateField, Filter, Transaction, } from '@google-cloud/firestore';
2
- import { collectionPath, docPath, } from 'firestore-repository/schema';
1
+ import { AggregateField, Filter, Transaction } from '@google-cloud/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)) : this.docRef(id).get());
13
- return this.fromFirestore(doc);
14
- }
15
- getOnSnapshot(id, next, error) {
16
- return this.docRef(id).onSnapshot((snapshot) => {
17
- next(this.fromFirestore(snapshot));
18
- }, error);
19
- }
20
- async list(query) {
21
- const { docs } = await toFirestoreQuery(this.db, query).get();
22
- return docs.map((doc) =>
23
- // biome-ignore lint/style/noNonNullAssertion: Query result items should have data
24
- this.fromFirestore(doc));
25
- }
26
- listOnSnapshot(query, next, error) {
27
- return toFirestoreQuery(this.db, query).onSnapshot((snapshot) => {
28
- // biome-ignore lint/style/noNonNullAssertion: Query result items should have data
29
- next(snapshot.docs.map((doc) => this.fromFirestore(doc)));
30
- }, error);
31
- }
32
- async aggregate(query, spec) {
33
- const aggregateSpec = {};
34
- for (const [k, v] of Object.entries(spec)) {
35
- switch (v.kind) {
36
- case 'count':
37
- aggregateSpec[k] = AggregateField.count();
38
- break;
39
- case 'sum':
40
- aggregateSpec[k] = AggregateField.sum(v.path);
41
- break;
42
- case 'average':
43
- aggregateSpec[k] = AggregateField.average(v.path);
44
- 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) : docRef.get());
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 docRef.onSnapshot((snapshot) => {
29
+ const doc = fromFirestore.document(snapshot);
30
+ next(doc ? mapper.fromFirestore(doc) : undefined);
31
+ }, error);
32
+ },
33
+ list: async (query) => {
34
+ const firestoreQuery = toFirestore.query(query);
35
+ const { docs } = await firestoreQuery.get();
36
+ return docs.values().map((doc) => mapper.fromFirestore(fromFirestore.documentMustExist(doc)));
37
+ },
38
+ listOnSnapshot: (query, next, error) => {
39
+ const firestoreQuery = toFirestore.query(query);
40
+ return firestoreQuery.onSnapshot((snapshot) => {
41
+ next(snapshot.docs.map((doc) => mapper.fromFirestore(fromFirestore.documentMustExist(doc))));
42
+ }, error);
43
+ },
44
+ aggregate: async (query, spec) => {
45
+ const aggregateSpec = {};
46
+ for (const [k, v] of Object.entries(spec)) {
47
+ switch (v.kind) {
48
+ case 'count':
49
+ aggregateSpec[k] = AggregateField.count();
50
+ break;
51
+ case 'sum':
52
+ aggregateSpec[k] = AggregateField.sum(v.path);
53
+ break;
54
+ case 'average':
55
+ aggregateSpec[k] = AggregateField.average(v.path);
56
+ break;
57
+ default:
58
+ return assertNever(v);
59
+ }
60
+ }
61
+ const firestoreQuery = toFirestore.query(query);
62
+ const res = await firestoreQuery.aggregate(aggregateSpec).get();
63
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- there is no way to infer correct type
64
+ return res.data();
65
+ },
66
+ create: async (model, options) => {
67
+ const docToWrite = mapper.toFirestore(model);
68
+ const docRef = toFirestore.docRef(docToWrite.ref);
69
+ const data = encode(docToWrite.data);
70
+ await (options?.tx ? options.tx.create(docRef, data) : docRef.create(data));
71
+ },
72
+ set: async (model, options) => {
73
+ const docToWrite = mapper.toFirestore(model);
74
+ const docRef = toFirestore.docRef(docToWrite.ref);
75
+ const data = encode(docToWrite.data);
76
+ await (options?.tx
77
+ ? options.tx instanceof Transaction
78
+ ? options.tx.set(docRef, data)
79
+ : options.tx.set(docRef, data)
80
+ : docRef.set(data));
81
+ },
82
+ delete: async (ref, options) => {
83
+ const docRef = toFirestore.docRef(mapper.toDocRef(ref));
84
+ await (options?.tx ? options.tx.delete(docRef) : docRef.delete());
85
+ },
86
+ batchGet: async (refs, options) => {
87
+ if (refs.length === 0) {
88
+ return [];
89
+ }
90
+ const docRefs = refs.map((ref) => toFirestore.docRef(mapper.toDocRef(ref)));
91
+ const docs = await (options?.tx ? options.tx.getAll(...docRefs) : db.getAll(...docRefs));
92
+ return docs.map((doc) => {
93
+ const d = fromFirestore.document(doc);
94
+ return d ? mapper.fromFirestore(d) : undefined;
95
+ });
96
+ },
97
+ batchSet: async (models, options) => {
98
+ const docs = models.map((m) => {
99
+ const d = mapper.toFirestore(m);
100
+ return { ref: d.ref, data: encode(d.data) };
101
+ });
102
+ await batchWriteOperation(docs, {
103
+ batch: (batch, doc) => batch.set(toFirestore.docRef(doc.ref), doc.data),
104
+ transaction: (tx, doc) => tx.set(toFirestore.docRef(doc.ref), doc.data),
105
+ }, options);
106
+ },
107
+ batchCreate: async (models, options) => {
108
+ const docs = models.map((m) => {
109
+ const d = mapper.toFirestore(m);
110
+ return { ref: d.ref, data: encode(d.data) };
111
+ });
112
+ await batchWriteOperation(docs, {
113
+ batch: (batch, doc) => batch.create(toFirestore.docRef(doc.ref), doc.data),
114
+ transaction: (tx, doc) => tx.create(toFirestore.docRef(doc.ref), doc.data),
115
+ }, options);
116
+ },
117
+ batchDelete: async (refs, options) => {
118
+ const docRefs = refs.map(mapper.toDocRef);
119
+ await batchWriteOperation(docRefs, {
120
+ batch: (batch, ref) => batch.delete(toFirestore.docRef(ref)),
121
+ transaction: (tx, ref) => tx.delete(toFirestore.docRef(ref)),
122
+ }, options);
123
+ },
124
+ };
125
+ };
126
+ const buildFirestoreUtilities = (db, collection) => {
127
+ const decodeSchema = buildDecodeSchema(collection.schema);
128
+ const encodeSchema = buildEncodeSchema(collection.schema, db);
129
+ const toFirestore = {
130
+ docRef: (ref) => db.doc(documentPath(collection, ref)),
131
+ query: (query) => {
132
+ let base;
133
+ if ('collection' in query.base) {
134
+ base = query.base.group
135
+ ? db.collectionGroup(query.base.collection.name)
136
+ : db.collection(collectionPath(query.base.collection, query.base.parent));
137
+ }
138
+ else if ('extends' in query.base) {
139
+ base = toFirestore.query(query.base.extends);
140
+ }
141
+ else {
142
+ return assertNever(query.base);
143
+ }
144
+ return (query.constraints?.reduce((q, constraint) => {
145
+ switch (constraint.kind) {
146
+ case 'where':
147
+ return q.where(toFirestore.filter(constraint.condition));
148
+ case 'orderBy':
149
+ return q.orderBy(constraint.field, constraint.direction);
150
+ case 'limit':
151
+ return q.limit(constraint.limit);
152
+ case 'limitToLast':
153
+ return q.limitToLast(constraint.limit);
154
+ case 'offset':
155
+ return q.offset(constraint.offset);
156
+ case 'startAt': {
157
+ const { cursor } = constraint;
158
+ return q.startAt(...cursor);
159
+ }
160
+ case 'startAfter': {
161
+ const { cursor } = constraint;
162
+ return q.startAfter(...cursor);
163
+ }
164
+ case 'endAt': {
165
+ const { cursor } = constraint;
166
+ return q.endAt(...cursor);
167
+ }
168
+ case 'endBefore': {
169
+ const { cursor } = constraint;
170
+ return q.endBefore(...cursor);
171
+ }
172
+ default:
173
+ return assertNever(constraint);
174
+ }
175
+ }, base) ?? base);
176
+ },
177
+ filter: (expr) => {
178
+ switch (expr.kind) {
179
+ case 'fieldValueCondition':
180
+ return Filter.where(expr.fieldPath, expr.opStr, expr.value);
181
+ case 'and':
182
+ return Filter.and(...expr.filters.map(toFirestore.filter));
183
+ case 'or':
184
+ return Filter.or(...expr.filters.map(toFirestore.filter));
45
185
  default:
46
- return assertNever(v);
186
+ return assertNever(expr);
47
187
  }
48
- }
49
- const res = await toFirestoreQuery(this.db, query).aggregate(aggregateSpec).get();
50
- return res.data();
51
- }
52
- /**
53
- * Create a new document
54
- * @throws If the document already exists
55
- */
56
- async create(doc, options) {
57
- const data = this.toFirestoreData(doc);
58
- await (options?.tx ? options.tx.create(this.docRef(doc), data) : this.docRef(doc).create(data));
59
- }
60
- async set(doc, options) {
61
- const data = this.toFirestoreData(doc);
62
- await (options?.tx
63
- ? options.tx instanceof Transaction
64
- ? options.tx.set(this.docRef(doc), data)
65
- : options.tx.set(this.docRef(doc), data)
66
- : this.docRef(doc).set(data));
67
- }
68
- async delete(id, options) {
69
- await (options?.tx ? options.tx.delete(this.docRef(id)) : this.docRef(id).delete());
70
- }
71
- /**
72
- * Get documents by multiple ID
73
- * example: [{id:1},{id:2},{id:5},{id:1}] -> [doc1,doc2,undefined,doc1]
74
- */
75
- async batchGet(ids, options) {
76
- if (ids.length === 0) {
77
- return [];
78
- }
79
- const docRefs = ids.map((id) => this.docRef(id));
80
- const docs = await (options?.tx ? options.tx.getAll(...docRefs) : this.db.getAll(...docRefs));
81
- return docs.map((doc) => this.fromFirestore(doc));
82
- }
83
- async batchSet(docs, options) {
84
- await this.batchWriteOperation(docs, {
85
- batch: (batch, doc) => batch.set(this.docRef(doc), this.toFirestoreData(doc)),
86
- transaction: (tx, doc) => tx.set(this.docRef(doc), this.toFirestoreData(doc)),
87
- }, options);
88
- }
89
- /**
90
- * Create multiple documents
91
- * The entire operation will fail if one creation fails
92
- */
93
- async batchCreate(docs, options) {
94
- await this.batchWriteOperation(docs, {
95
- batch: (batch, doc) => batch.create(this.docRef(doc), this.toFirestoreData(doc)),
96
- transaction: (tx, doc) => tx.create(this.docRef(doc), this.toFirestoreData(doc)),
97
- }, options);
98
- }
99
- async batchDelete(ids, options) {
100
- await this.batchWriteOperation(ids, {
101
- batch: (batch, id) => batch.delete(this.docRef(id)),
102
- transaction: (tx, id) => tx.delete(this.docRef(id)),
103
- }, options);
104
- }
105
- async batchWriteOperation(targets, runner, options) {
188
+ },
189
+ };
190
+ const fromFirestore = {
191
+ documentMustExist: (document) => {
192
+ const data = document.data();
193
+ if (!data) {
194
+ throw new Error('document must exist');
195
+ }
196
+ return {
197
+ ref: fromFirestore.docRef(document.ref),
198
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- Zod output is typed by schema
199
+ data: decodeSchema.parse(data),
200
+ };
201
+ },
202
+ document: (document) => {
203
+ if (!document.exists) {
204
+ return undefined;
205
+ }
206
+ return fromFirestore.documentMustExist(document);
207
+ },
208
+ docRef: (ref) => {
209
+ const docRef = [];
210
+ let currentRef = ref;
211
+ while (currentRef != null) {
212
+ docRef.push(currentRef.id);
213
+ currentRef = currentRef.parent.parent;
214
+ }
215
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- cannot infer type here
216
+ return docRef.reverse();
217
+ },
218
+ };
219
+ const batchWriteOperation = async (targets, runner, options) => {
106
220
  const tx = options?.tx;
107
221
  if (tx) {
108
222
  if (tx instanceof Transaction) {
109
- targets.forEach((target) => runner.transaction(tx, target));
223
+ targets.forEach((target) => void runner.transaction(tx, target));
110
224
  }
111
225
  else {
112
- targets.forEach((target) => runner.batch(tx, target));
226
+ targets.forEach((target) => void runner.batch(tx, target));
113
227
  }
114
228
  }
115
229
  else {
116
- const batch = this.db.batch();
117
- targets.forEach((target) => runner.batch(batch, target));
230
+ const batch = db.batch();
231
+ targets.forEach((target) => void runner.batch(batch, target));
118
232
  await batch.commit();
119
233
  }
120
- }
121
- docRef(id) {
122
- return this.db.doc(docPath(this.collection, id));
123
- }
124
- fromFirestore(doc) {
125
- const data = doc.data();
126
- const [id, ...parentPath] = docPathElements(doc.ref);
127
- return data
128
- ? {
129
- ...this.collection.data.from(data),
130
- ...this.collection.collectionPath.from(parentPath),
131
- ...this.collection.id.from(id.id),
132
- }
133
- : undefined;
134
- }
135
- toFirestoreData(data) {
136
- return this.collection.data.to(data);
137
- }
138
- }
139
- /**
140
- * Obtain document path elements from DocumentReference
141
- */
142
- export const docPathElements = (doc) => {
143
- const parentPath = [];
144
- let cursor = doc.parent.parent;
145
- while (cursor) {
146
- parentPath.push({ id: cursor.id, collection: cursor.parent.id });
147
- cursor = cursor.parent.parent;
148
- }
149
- return [{ collection: doc.parent.id, id: doc.id }, ...parentPath];
150
- };
151
- // OPTIMIZE: cache query
152
- export const toFirestoreQuery = (db, query) => {
153
- let base;
154
- switch (query.base.kind) {
155
- case 'collection':
156
- base = db.collection(collectionPath(query.base.collection, query.base.parentId));
157
- break;
158
- case 'collectionGroup':
159
- base = db.collectionGroup(query.base.collection.name);
160
- break;
161
- case 'extends':
162
- base = toFirestoreQuery(db, query.base.query);
163
- break;
164
- default:
165
- base = assertNever(query.base);
166
- }
167
- return (query.constraints?.reduce((q, constraint) => {
168
- switch (constraint.kind) {
169
- case 'where':
170
- case 'or':
171
- case 'and':
172
- return q.where(toFirestoreFilter(constraint));
173
- case 'orderBy':
174
- return q.orderBy(constraint.field, constraint.direction);
175
- case 'limit':
176
- return q.limit(constraint.limit);
177
- case 'limitToLast':
178
- return q.limitToLast(constraint.limit);
179
- case 'offset':
180
- return q.offset(constraint.offset);
181
- case 'startAt': {
182
- const { cursor } = constraint;
183
- return q.startAt(...cursor);
184
- }
185
- case 'startAfter': {
186
- const { cursor } = constraint;
187
- return q.startAfter(...cursor);
188
- }
189
- case 'endAt': {
190
- const { cursor } = constraint;
191
- return q.endAt(...cursor);
192
- }
193
- case 'endBefore': {
194
- const { cursor } = constraint;
195
- return q.endBefore(...cursor);
196
- }
197
- default:
198
- return assertNever(constraint);
199
- }
200
- }, base) ?? base);
201
- };
202
- export const toFirestoreFilter = (expr) => {
203
- switch (expr.kind) {
204
- case 'where':
205
- return Filter.where(expr.fieldPath, expr.opStr, expr.value);
206
- case 'and':
207
- return Filter.and(...expr.filters.map(toFirestoreFilter));
208
- case 'or':
209
- return Filter.or(...expr.filters.map(toFirestoreFilter));
210
- default:
211
- return assertNever(expr);
212
- }
234
+ };
235
+ return { fromFirestore, toFirestore, batchWriteOperation, encodeSchema };
213
236
  };
214
- /**
215
- * A query offset constraint
216
- */
217
- export const offset = (offset) => ({ kind: 'offset', offset });
@@ -0,0 +1,5 @@
1
+ import type { Offset } from 'firestore-repository/query';
2
+ /**
3
+ * Creates an offset constraint
4
+ */
5
+ export declare const offset: (offset: number) => Offset;
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Creates an offset constraint
3
+ */
4
+ export const offset = (offset) => ({ kind: 'offset', offset });
package/package.json CHANGED
@@ -1,51 +1,56 @@
1
1
  {
2
2
  "name": "@firestore-repository/google-cloud-firestore",
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-admin",
9
+ "firestore",
10
+ "google-cloud",
11
+ "orm",
12
+ "repository"
13
+ ],
5
14
  "homepage": "https://github.com/ikenox/firestore-repository",
15
+ "license": "MIT",
16
+ "author": "Naoto Ikeno <ikenox@gmail.com>",
6
17
  "repository": {
7
18
  "type": "git",
8
19
  "url": "https://github.com/ikenox/firestore-repository.git"
9
20
  },
21
+ "files": [
22
+ "build",
23
+ "!**/*.tsbuildinfo"
24
+ ],
10
25
  "type": "module",
11
- "dependencies": {
12
- "@google-cloud/firestore": "^7.10.0",
13
- "firestore-repository": "0.4.2"
14
- },
15
26
  "exports": {
16
27
  ".": {
28
+ "@firestore-repository/source": "./src/index.ts",
17
29
  "import": {
18
30
  "types": "./build/esm/index.d.ts",
19
31
  "default": "./build/esm/index.js"
20
32
  }
21
33
  },
22
34
  "./*": {
35
+ "@firestore-repository/source": "./src/*.ts",
23
36
  "import": {
24
37
  "types": "./build/esm/*.d.ts",
25
38
  "default": "./build/esm/*.js"
26
39
  }
27
40
  }
28
41
  },
29
- "files": [
30
- "build",
31
- "!**/*.tsbuildinfo"
32
- ],
33
- "keywords": [
34
- "firestore",
35
- "orm",
36
- "database",
37
- "repository",
38
- "firebase",
39
- "firebase-admin",
40
- "google-cloud"
41
- ],
42
- "author": "Naoto Ikeno <ikenox@gmail.com>",
43
- "license": "MIT",
44
42
  "publishConfig": {
45
43
  "access": "public"
46
44
  },
45
+ "dependencies": {
46
+ "@google-cloud/firestore": "^8.3.0",
47
+ "zod": "^4.3.6",
48
+ "firestore-repository": "0.5.0"
49
+ },
50
+ "engines": {
51
+ "node": ">=18"
52
+ },
47
53
  "scripts": {
48
- "typecheck": "tsc --noEmit",
49
54
  "build": "rm -rf build/ && tsc -b tsconfig.build.json"
50
55
  }
51
56
  }