@firestore-repository/google-cloud-firestore 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 +165 -82
- package/build/esm/codec.d.ts +5 -0
- package/build/esm/codec.js +173 -0
- package/build/esm/index.d.ts +23 -49
- package/build/esm/index.js +222 -203
- package/build/esm/query.d.ts +5 -0
- package/build/esm/query.js +4 -0
- package/package.json +26 -21
package/README.md
CHANGED
|
@@ -4,13 +4,14 @@
|
|
|
4
4
|
|
|
5
5
|
# firestore-repository
|
|
6
6
|
|
|
7
|
-
A
|
|
7
|
+
A minimal and universal Firestore client (Repository Pattern) for TypeScript
|
|
8
8
|
|
|
9
9
|
## Features
|
|
10
10
|
|
|
11
|
-
- 🚀 **
|
|
12
|
-
- 🌐 **
|
|
13
|
-
-
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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 =
|
|
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
|
-
|
|
70
|
-
name: 'John Doe',
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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(
|
|
87
|
+
const doc = await repository.get('user1');
|
|
80
88
|
|
|
81
|
-
// Listen a document
|
|
82
|
-
repository.getOnSnapshot(
|
|
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(
|
|
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 {
|
|
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
|
-
|
|
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(
|
|
100
|
-
console.log(docs);
|
|
118
|
+
const docs = await repository.list(q);
|
|
101
119
|
|
|
102
|
-
// Listen documents
|
|
103
|
-
repository.listOnSnapshot(
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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([
|
|
138
|
+
const users = await repository.batchGet(['user1', 'user2']);
|
|
124
139
|
|
|
125
140
|
// Set multiple documents
|
|
126
141
|
await repository.batchSet([
|
|
127
|
-
{
|
|
128
|
-
|
|
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([
|
|
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.
|
|
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
|
-
|
|
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(
|
|
163
|
+
await repository.batchSet(
|
|
164
|
+
[
|
|
165
|
+
/* ... */
|
|
166
|
+
],
|
|
167
|
+
{ tx: batch },
|
|
164
168
|
);
|
|
165
|
-
await repository.delete(
|
|
166
|
-
await repository.batchDelete([
|
|
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
|
|
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(
|
|
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
|
-
|
|
190
|
-
|
|
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(
|
|
196
|
-
await repository.batchDelete([
|
|
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 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
|
+
}
|
package/build/esm/index.d.ts
CHANGED
|
@@ -1,59 +1,33 @@
|
|
|
1
|
-
import
|
|
2
|
-
import type
|
|
3
|
-
import type {
|
|
4
|
-
|
|
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:
|
|
6
|
+
transaction: firestore.Transaction;
|
|
7
|
+
writeBatch: firestore.WriteBatch;
|
|
8
|
+
query: firestore.Query;
|
|
11
9
|
};
|
|
12
|
-
|
|
13
|
-
export
|
|
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
|
-
*
|
|
13
|
+
* Creates a new document
|
|
25
14
|
* @throws If the document already exists
|
|
26
15
|
*/
|
|
27
|
-
create(
|
|
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
|
-
*
|
|
32
|
-
*
|
|
18
|
+
* Creates multiple documents.
|
|
19
|
+
* The entire operation fails if any creation fails.
|
|
33
20
|
*/
|
|
34
|
-
|
|
35
|
-
batchSet(docs: Model<T>[], options?: WriteTransactionOption): Promise<void>;
|
|
21
|
+
batchCreate: (docs: Model['write'][], options?: WriteTransactionOption<Env>) => Promise<void>;
|
|
36
22
|
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
23
|
+
* Gets multiple documents by their IDs.
|
|
24
|
+
* @example [{id:1}, {id:2}, {id:5}, {id:1}] -> [doc1, doc2, undefined, doc1]
|
|
39
25
|
*/
|
|
40
|
-
|
|
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
|
-
|
|
52
|
-
*/
|
|
53
|
-
export declare const
|
|
54
|
-
|
|
55
|
-
export declare const
|
|
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>;
|
package/build/esm/index.js
CHANGED
|
@@ -1,217 +1,236 @@
|
|
|
1
|
-
import { AggregateField, Filter, Transaction
|
|
2
|
-
import { collectionPath,
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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(
|
|
186
|
+
return assertNever(expr);
|
|
47
187
|
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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 =
|
|
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
|
-
|
|
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 });
|
package/package.json
CHANGED
|
@@ -1,51 +1,56 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@firestore-repository/google-cloud-firestore",
|
|
3
|
-
"version": "0.
|
|
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.1"
|
|
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
|
}
|