@naturalcycles/firestore-lib 2.4.0 → 2.6.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.
@@ -1,5 +1,5 @@
1
1
  import type { Firestore, Query, Transaction } from '@google-cloud/firestore';
2
- import type { CommonDB, CommonDBOptions, CommonDBSaveOptions, CommonDBStreamOptions, CommonDBSupport, CommonDBTransactionOptions, DBQuery, DBTransaction, DBTransactionFn, RunQueryResult } from '@naturalcycles/db-lib';
2
+ import type { CommonDB, CommonDBOptions, CommonDBReadOptions, CommonDBSaveOptions, CommonDBStreamOptions, CommonDBSupport, CommonDBTransactionOptions, DBQuery, DBTransaction, DBTransactionFn, RunQueryResult } from '@naturalcycles/db-lib';
3
3
  import { BaseCommonDB } from '@naturalcycles/db-lib';
4
4
  import type { ObjectWithId, StringMap } from '@naturalcycles/js-lib/types';
5
5
  import type { ReadableTyped } from '@naturalcycles/nodejs-lib/stream';
@@ -8,6 +8,8 @@ export interface FirestoreDBCfg {
8
8
  }
9
9
  export interface FirestoreDBOptions extends CommonDBOptions {
10
10
  }
11
+ export interface FirestoreDBReadOptions extends CommonDBReadOptions {
12
+ }
11
13
  export interface FirestoreDBSaveOptions<ROW extends ObjectWithId> extends CommonDBSaveOptions<ROW> {
12
14
  }
13
15
  export declare class RollbackError extends Error {
@@ -17,14 +19,18 @@ export declare class FirestoreDB extends BaseCommonDB implements CommonDB {
17
19
  cfg: FirestoreDBCfg;
18
20
  constructor(cfg: FirestoreDBCfg);
19
21
  support: CommonDBSupport;
20
- getByIds<ROW extends ObjectWithId>(table: string, ids: string[], opt?: FirestoreDBOptions): Promise<ROW[]>;
22
+ getByIds<ROW extends ObjectWithId>(table: string, ids: string[], opt?: FirestoreDBReadOptions): Promise<ROW[]>;
23
+ multiGetByIds<ROW extends ObjectWithId>(map: StringMap<string[]>, opt?: CommonDBReadOptions): Promise<StringMap<ROW[]>>;
21
24
  runQuery<ROW extends ObjectWithId>(q: DBQuery<ROW>, opt?: FirestoreDBOptions): Promise<RunQueryResult<ROW>>;
22
- runFirestoreQuery<ROW extends ObjectWithId>(q: Query, _opt?: FirestoreDBOptions): Promise<ROW[]>;
25
+ runFirestoreQuery<ROW extends ObjectWithId>(q: Query): Promise<ROW[]>;
23
26
  runQueryCount<ROW extends ObjectWithId>(q: DBQuery<ROW>, _opt?: FirestoreDBOptions): Promise<number>;
24
27
  streamQuery<ROW extends ObjectWithId>(q: DBQuery<ROW>, _opt?: CommonDBStreamOptions): ReadableTyped<ROW>;
25
28
  saveBatch<ROW extends ObjectWithId>(table: string, rows: ROW[], opt?: FirestoreDBSaveOptions<ROW>): Promise<void>;
29
+ multiSaveBatch<ROW extends ObjectWithId>(map: StringMap<ROW[]>, opt?: FirestoreDBSaveOptions<ROW>): Promise<void>;
30
+ patchById<ROW extends ObjectWithId>(table: string, id: string, patch: Partial<ROW>, opt?: FirestoreDBOptions): Promise<void>;
26
31
  deleteByQuery<ROW extends ObjectWithId>(q: DBQuery<ROW>, opt?: FirestoreDBOptions): Promise<number>;
27
32
  deleteByIds(table: string, ids: string[], opt?: FirestoreDBOptions): Promise<number>;
33
+ multiDeleteByIds(map: StringMap<string[]>, opt?: FirestoreDBOptions): Promise<number>;
28
34
  private querySnapshotToArray;
29
35
  runInTransaction(fn: DBTransactionFn, opt?: CommonDBTransactionOptions): Promise<void>;
30
36
  /**
@@ -47,6 +47,29 @@ export class FirestoreDB extends BaseCommonDB {
47
47
  })
48
48
  .filter(_isTruthy);
49
49
  }
50
+ async multiGetByIds(map, opt = {}) {
51
+ const result = {};
52
+ const { firestore } = this.cfg;
53
+ const refs = [];
54
+ for (const [table, ids] of _stringMapEntries(map)) {
55
+ result[table] = [];
56
+ const col = firestore.collection(table);
57
+ refs.push(...ids.map(id => col.doc(escapeDocId(id))));
58
+ }
59
+ const snapshots = await (opt.tx?.tx || firestore).getAll(...refs);
60
+ snapshots.forEach(snap => {
61
+ const data = snap.data();
62
+ if (data === undefined)
63
+ return;
64
+ const table = snap.ref.parent.id;
65
+ const row = {
66
+ id: unescapeDocId(snap.id),
67
+ ...data,
68
+ };
69
+ result[table].push(row);
70
+ });
71
+ return result;
72
+ }
50
73
  // QUERY
51
74
  async runQuery(q, opt) {
52
75
  const idFilter = q._filters.find(f => f.name === 'id');
@@ -57,14 +80,14 @@ export class FirestoreDB extends BaseCommonDB {
57
80
  };
58
81
  }
59
82
  const firestoreQuery = dbQueryToFirestoreQuery(q, this.cfg.firestore.collection(q.table));
60
- let rows = await this.runFirestoreQuery(firestoreQuery, opt);
83
+ let rows = await this.runFirestoreQuery(firestoreQuery);
61
84
  // Special case when projection query didn't specify 'id'
62
85
  if (q._selectedFieldNames && !q._selectedFieldNames.includes('id')) {
63
86
  rows = rows.map(r => _omit(r, ['id']));
64
87
  }
65
88
  return { rows };
66
89
  }
67
- async runFirestoreQuery(q, _opt) {
90
+ async runFirestoreQuery(q) {
68
91
  return this.querySnapshotToArray(await q.get());
69
92
  }
70
93
  async runQueryCount(q, _opt) {
@@ -90,19 +113,65 @@ export class FirestoreDB extends BaseCommonDB {
90
113
  const { tx } = opt.tx;
91
114
  for (const row of rows) {
92
115
  _assert(row.id, `firestore-db doesn't support id auto-generation, but empty id was provided in saveBatch`);
93
- tx[method](col.doc(escapeDocId(row.id)), _filterUndefinedValues(row));
116
+ const { id, ...rowWithoutId } = row;
117
+ tx[method](col.doc(escapeDocId(id)), _filterUndefinedValues(rowWithoutId));
94
118
  }
95
119
  return;
96
120
  }
97
- // Firestore allows max 500 items in one batch
98
- await pMap(_chunk(rows, 500), async (chunk) => {
121
+ await pMap(_chunk(rows, MAX_ITEMS), async (chunk) => {
122
+ // .batch is called "Atomic batch writer"
123
+ // Executes multiple writes in a single atomic transaction-like commit — all succeed or all fail.
124
+ // If any write in the batch fails (e.g., permission error, missing doc), the whole batch fails.
125
+ // Good for small, related sets of writes where consistency is critical.
99
126
  const batch = firestore.batch();
100
127
  for (const row of chunk) {
101
128
  _assert(row.id, `firestore-db doesn't support id auto-generation, but empty id was provided in saveBatch`);
102
- batch[method](col.doc(escapeDocId(row.id)), _filterUndefinedValues(row));
129
+ const { id, ...rowWithoutId } = row;
130
+ batch[method](col.doc(escapeDocId(id)), _filterUndefinedValues(rowWithoutId));
131
+ }
132
+ await batch.commit();
133
+ }, { concurrency: FIRESTORE_RECOMMENDED_CONCURRENCY });
134
+ }
135
+ async multiSaveBatch(map, opt = {}) {
136
+ const { firestore } = this.cfg;
137
+ const method = methodMap[opt.saveMethod] || 'set';
138
+ if (opt.tx) {
139
+ const { tx } = opt.tx;
140
+ for (const [table, rows] of _stringMapEntries(map)) {
141
+ const col = firestore.collection(table);
142
+ for (const row of rows) {
143
+ _assert(row.id, `firestore-db doesn't support id auto-generation, but empty id was provided in multiSaveBatch`);
144
+ const { id, ...rowWithoutId } = row;
145
+ tx[method](col.doc(escapeDocId(id)), _filterUndefinedValues(rowWithoutId));
146
+ }
147
+ }
148
+ return;
149
+ }
150
+ const tableRows = [];
151
+ for (const [table, rows] of _stringMapEntries(map)) {
152
+ for (const row of rows) {
153
+ tableRows.push([table, row]);
154
+ }
155
+ }
156
+ await pMap(_chunk(tableRows, MAX_ITEMS), async (chunk) => {
157
+ const batch = firestore.batch();
158
+ for (const [table, row] of chunk) {
159
+ _assert(row.id, `firestore-db doesn't support id auto-generation, but empty id was provided in multiSaveBatch`);
160
+ const { id, ...rowWithoutId } = row;
161
+ batch[method](firestore.collection(table).doc(escapeDocId(id)), _filterUndefinedValues(rowWithoutId));
103
162
  }
104
163
  await batch.commit();
105
- }, { concurrency: 1 });
164
+ }, { concurrency: FIRESTORE_RECOMMENDED_CONCURRENCY });
165
+ }
166
+ async patchById(table, id, patch, opt = {}) {
167
+ const { firestore } = this.cfg;
168
+ const col = firestore.collection(table);
169
+ if (opt.tx) {
170
+ const { tx } = opt.tx;
171
+ tx.update(col.doc(escapeDocId(id)), patch);
172
+ return;
173
+ }
174
+ await col.doc(escapeDocId(id)).update(patch);
106
175
  }
107
176
  // DELETE
108
177
  async deleteByQuery(q, opt) {
@@ -128,24 +197,44 @@ export class FirestoreDB extends BaseCommonDB {
128
197
  }
129
198
  return ids.length;
130
199
  }
131
- await pMap(_chunk(ids, 500), async (chunk) => {
200
+ await pMap(_chunk(ids, MAX_ITEMS), async (chunk) => {
132
201
  const batch = firestore.batch();
133
202
  for (const id of chunk) {
134
203
  batch.delete(col.doc(escapeDocId(id)));
135
204
  }
136
205
  await batch.commit();
137
- });
206
+ }, { concurrency: FIRESTORE_RECOMMENDED_CONCURRENCY });
138
207
  return ids.length;
139
208
  }
209
+ async multiDeleteByIds(map, opt = {}) {
210
+ const { firestore } = this.cfg;
211
+ const refs = [];
212
+ for (const [table, ids] of _stringMapEntries(map)) {
213
+ const col = firestore.collection(table);
214
+ refs.push(...ids.map(id => col.doc(escapeDocId(id))));
215
+ }
216
+ if (opt.tx) {
217
+ const { tx } = opt.tx;
218
+ for (const ref of refs) {
219
+ tx.delete(ref);
220
+ }
221
+ }
222
+ else {
223
+ await pMap(_chunk(refs, MAX_ITEMS), async (chunk) => {
224
+ const batch = firestore.batch();
225
+ for (const ref of chunk) {
226
+ batch.delete(ref);
227
+ }
228
+ await batch.commit();
229
+ }, { concurrency: FIRESTORE_RECOMMENDED_CONCURRENCY });
230
+ }
231
+ return refs.length;
232
+ }
140
233
  querySnapshotToArray(qs) {
141
- const rows = [];
142
- qs.forEach(doc => {
143
- rows.push({
144
- id: unescapeDocId(doc.id),
145
- ...doc.data(),
146
- });
147
- });
148
- return rows;
234
+ return qs.docs.map(doc => ({
235
+ id: unescapeDocId(doc.id),
236
+ ...doc.data(),
237
+ }));
149
238
  }
150
239
  async runInTransaction(fn, opt = {}) {
151
240
  const { readOnly } = opt;
@@ -174,7 +263,6 @@ export class FirestoreDB extends BaseCommonDB {
174
263
  const batch = firestore.batch();
175
264
  for (const [id, increment] of _stringMapEntries(incrementMap)) {
176
265
  batch.set(col.doc(escapeDocId(id)), {
177
- // todo: lazy-load FieldValue
178
266
  [prop]: FieldValue.increment(increment),
179
267
  }, { merge: true });
180
268
  }
@@ -214,3 +302,7 @@ export class FirestoreDBTransaction {
214
302
  return await this.db.deleteByIds(table, ids, { ...opt, tx: this });
215
303
  }
216
304
  }
305
+ // Datastore (also Firestore and other Google APIs) supports max 500 of items when saving/deleting, etc.
306
+ const MAX_ITEMS = 500;
307
+ // It's an empyrical value, but anything less than infinity is better than infinity
308
+ const FIRESTORE_RECOMMENDED_CONCURRENCY = 8;
@@ -5,16 +5,15 @@ const OP_MAP = {
5
5
  // in: 'array-contains',
6
6
  };
7
7
  export function dbQueryToFirestoreQuery(dbQuery, emptyQuery) {
8
+ let q = emptyQuery;
8
9
  // filter
9
- // eslint-disable-next-line unicorn/no-array-reduce
10
- let q = dbQuery._filters.reduce((q, f) => {
11
- return q.where(f.name, OP_MAP[f.op] || f.op, f.val);
12
- }, emptyQuery);
10
+ for (const f of dbQuery._filters) {
11
+ q = q.where(f.name, OP_MAP[f.op] || f.op, f.val);
12
+ }
13
13
  // order
14
- // eslint-disable-next-line unicorn/no-array-reduce
15
- q = dbQuery._orders.reduce((q, ord) => {
16
- return q.orderBy(ord.name, ord.descending ? 'desc' : 'asc');
17
- }, q);
14
+ for (const ord of dbQuery._orders) {
15
+ q = q.orderBy(ord.name, ord.descending ? 'desc' : 'asc');
16
+ }
18
17
  // limit
19
18
  q = q.limit(dbQuery._limitValue);
20
19
  // selectedFields
package/package.json CHANGED
@@ -38,7 +38,7 @@
38
38
  "engines": {
39
39
  "node": ">=22.12.0"
40
40
  },
41
- "version": "2.4.0",
41
+ "version": "2.6.0",
42
42
  "description": "Firestore implementation of CommonDB interface",
43
43
  "author": "Natural Cycles Team",
44
44
  "license": "MIT",
@@ -1,14 +1,17 @@
1
1
  import type {
2
+ DocumentReference,
2
3
  Firestore,
3
4
  Query,
4
5
  QueryDocumentSnapshot,
5
6
  QuerySnapshot,
6
7
  Transaction,
8
+ UpdateData,
7
9
  } from '@google-cloud/firestore'
8
10
  import { FieldValue } from '@google-cloud/firestore'
9
11
  import type {
10
12
  CommonDB,
11
13
  CommonDBOptions,
14
+ CommonDBReadOptions,
12
15
  CommonDBSaveMethod,
13
16
  CommonDBSaveOptions,
14
17
  CommonDBStreamOptions,
@@ -36,6 +39,7 @@ export interface FirestoreDBCfg {
36
39
  }
37
40
 
38
41
  export interface FirestoreDBOptions extends CommonDBOptions {}
42
+ export interface FirestoreDBReadOptions extends CommonDBReadOptions {}
39
43
  export interface FirestoreDBSaveOptions<ROW extends ObjectWithId>
40
44
  extends CommonDBSaveOptions<ROW> {}
41
45
 
@@ -68,7 +72,7 @@ export class FirestoreDB extends BaseCommonDB implements CommonDB {
68
72
  override async getByIds<ROW extends ObjectWithId>(
69
73
  table: string,
70
74
  ids: string[],
71
- opt: FirestoreDBOptions = {},
75
+ opt: FirestoreDBReadOptions = {},
72
76
  ): Promise<ROW[]> {
73
77
  if (!ids.length) return []
74
78
 
@@ -85,12 +89,40 @@ export class FirestoreDB extends BaseCommonDB implements CommonDB {
85
89
  if (data === undefined) return
86
90
  return {
87
91
  id: unescapeDocId(doc.id),
88
- ...(data as any),
89
- }
92
+ ...data,
93
+ } as ROW
90
94
  })
91
95
  .filter(_isTruthy)
92
96
  }
93
97
 
98
+ override async multiGetByIds<ROW extends ObjectWithId>(
99
+ map: StringMap<string[]>,
100
+ opt: CommonDBReadOptions = {},
101
+ ): Promise<StringMap<ROW[]>> {
102
+ const result: StringMap<ROW[]> = {}
103
+ const { firestore } = this.cfg
104
+ const refs: DocumentReference[] = []
105
+ for (const [table, ids] of _stringMapEntries(map)) {
106
+ result[table] = []
107
+ const col = firestore.collection(table)
108
+ refs.push(...ids.map(id => col.doc(escapeDocId(id))))
109
+ }
110
+
111
+ const snapshots = await ((opt.tx as FirestoreDBTransaction)?.tx || firestore).getAll(...refs)
112
+ snapshots.forEach(snap => {
113
+ const data = snap.data()
114
+ if (data === undefined) return
115
+ const table = snap.ref.parent.id
116
+ const row = {
117
+ id: unescapeDocId(snap.id),
118
+ ...data,
119
+ } as ROW
120
+ result[table]!.push(row)
121
+ })
122
+
123
+ return result
124
+ }
125
+
94
126
  // QUERY
95
127
  override async runQuery<ROW extends ObjectWithId>(
96
128
  q: DBQuery<ROW>,
@@ -106,7 +138,7 @@ export class FirestoreDB extends BaseCommonDB implements CommonDB {
106
138
 
107
139
  const firestoreQuery = dbQueryToFirestoreQuery(q, this.cfg.firestore.collection(q.table))
108
140
 
109
- let rows = await this.runFirestoreQuery<ROW>(firestoreQuery, opt)
141
+ let rows = await this.runFirestoreQuery<ROW>(firestoreQuery)
110
142
 
111
143
  // Special case when projection query didn't specify 'id'
112
144
  if (q._selectedFieldNames && !q._selectedFieldNames.includes('id')) {
@@ -116,10 +148,7 @@ export class FirestoreDB extends BaseCommonDB implements CommonDB {
116
148
  return { rows }
117
149
  }
118
150
 
119
- async runFirestoreQuery<ROW extends ObjectWithId>(
120
- q: Query,
121
- _opt?: FirestoreDBOptions,
122
- ): Promise<ROW[]> {
151
+ async runFirestoreQuery<ROW extends ObjectWithId>(q: Query): Promise<ROW[]> {
123
152
  return this.querySnapshotToArray(await q.get())
124
153
  }
125
154
 
@@ -165,15 +194,22 @@ export class FirestoreDB extends BaseCommonDB implements CommonDB {
165
194
  `firestore-db doesn't support id auto-generation, but empty id was provided in saveBatch`,
166
195
  )
167
196
 
168
- tx[method as 'set' | 'create'](col.doc(escapeDocId(row.id)), _filterUndefinedValues(row))
197
+ const { id, ...rowWithoutId } = row
198
+ tx[method as 'set' | 'create'](
199
+ col.doc(escapeDocId(id)),
200
+ _filterUndefinedValues(rowWithoutId),
201
+ )
169
202
  }
170
203
  return
171
204
  }
172
205
 
173
- // Firestore allows max 500 items in one batch
174
206
  await pMap(
175
- _chunk(rows, 500),
207
+ _chunk(rows, MAX_ITEMS),
176
208
  async chunk => {
209
+ // .batch is called "Atomic batch writer"
210
+ // Executes multiple writes in a single atomic transaction-like commit — all succeed or all fail.
211
+ // If any write in the batch fails (e.g., permission error, missing doc), the whole batch fails.
212
+ // Good for small, related sets of writes where consistency is critical.
177
213
  const batch = firestore.batch()
178
214
 
179
215
  for (const row of chunk) {
@@ -181,18 +217,95 @@ export class FirestoreDB extends BaseCommonDB implements CommonDB {
181
217
  row.id,
182
218
  `firestore-db doesn't support id auto-generation, but empty id was provided in saveBatch`,
183
219
  )
220
+ const { id, ...rowWithoutId } = row
221
+ batch[method as 'set' | 'create'](
222
+ col.doc(escapeDocId(id)),
223
+ _filterUndefinedValues(rowWithoutId),
224
+ )
225
+ }
226
+
227
+ await batch.commit()
228
+ },
229
+ { concurrency: FIRESTORE_RECOMMENDED_CONCURRENCY },
230
+ )
231
+ }
232
+
233
+ override async multiSaveBatch<ROW extends ObjectWithId>(
234
+ map: StringMap<ROW[]>,
235
+ opt: FirestoreDBSaveOptions<ROW> = {},
236
+ ): Promise<void> {
237
+ const { firestore } = this.cfg
238
+ const method: SaveOp = methodMap[opt.saveMethod!] || 'set'
239
+
240
+ if (opt.tx) {
241
+ const { tx } = opt.tx as FirestoreDBTransaction
242
+
243
+ for (const [table, rows] of _stringMapEntries(map)) {
244
+ const col = firestore.collection(table)
245
+ for (const row of rows) {
246
+ _assert(
247
+ row.id,
248
+ `firestore-db doesn't support id auto-generation, but empty id was provided in multiSaveBatch`,
249
+ )
250
+
251
+ const { id, ...rowWithoutId } = row
252
+ tx[method as 'set' | 'create'](
253
+ col.doc(escapeDocId(id)),
254
+ _filterUndefinedValues(rowWithoutId),
255
+ )
256
+ }
257
+ }
258
+ return
259
+ }
260
+
261
+ const tableRows: TableRow<ROW>[] = []
262
+ for (const [table, rows] of _stringMapEntries(map)) {
263
+ for (const row of rows) {
264
+ tableRows.push([table, row])
265
+ }
266
+ }
267
+
268
+ await pMap(
269
+ _chunk(tableRows, MAX_ITEMS),
270
+ async chunk => {
271
+ const batch = firestore.batch()
272
+
273
+ for (const [table, row] of chunk) {
274
+ _assert(
275
+ row.id,
276
+ `firestore-db doesn't support id auto-generation, but empty id was provided in multiSaveBatch`,
277
+ )
278
+ const { id, ...rowWithoutId } = row
184
279
  batch[method as 'set' | 'create'](
185
- col.doc(escapeDocId(row.id)),
186
- _filterUndefinedValues(row),
280
+ firestore.collection(table).doc(escapeDocId(id)),
281
+ _filterUndefinedValues(rowWithoutId),
187
282
  )
188
283
  }
189
284
 
190
285
  await batch.commit()
191
286
  },
192
- { concurrency: 1 },
287
+ { concurrency: FIRESTORE_RECOMMENDED_CONCURRENCY },
193
288
  )
194
289
  }
195
290
 
291
+ override async patchById<ROW extends ObjectWithId>(
292
+ table: string,
293
+ id: string,
294
+ patch: Partial<ROW>,
295
+ opt: FirestoreDBOptions = {},
296
+ ): Promise<void> {
297
+ const { firestore } = this.cfg
298
+ const col = firestore.collection(table)
299
+
300
+ if (opt.tx) {
301
+ const { tx } = opt.tx as FirestoreDBTransaction
302
+ tx.update(col.doc(escapeDocId(id)), patch as UpdateData<ROW>)
303
+ return
304
+ }
305
+
306
+ await col.doc(escapeDocId(id)).update(patch)
307
+ }
308
+
196
309
  // DELETE
197
310
  override async deleteByQuery<ROW extends ObjectWithId>(
198
311
  q: DBQuery<ROW>,
@@ -208,7 +321,7 @@ export class FirestoreDB extends BaseCommonDB implements CommonDB {
208
321
  q.select([]),
209
322
  this.cfg.firestore.collection(q.table),
210
323
  )
211
- ids = (await this.runFirestoreQuery<ROW>(firestoreQuery)).map(obj => obj.id)
324
+ ids = (await this.runFirestoreQuery<ObjectWithId>(firestoreQuery)).map(obj => obj.id)
212
325
  }
213
326
 
214
327
  await this.deleteByIds(q.table, ids, opt)
@@ -233,30 +346,62 @@ export class FirestoreDB extends BaseCommonDB implements CommonDB {
233
346
  return ids.length
234
347
  }
235
348
 
236
- await pMap(_chunk(ids, 500), async chunk => {
237
- const batch = firestore.batch()
238
-
239
- for (const id of chunk) {
240
- batch.delete(col.doc(escapeDocId(id)))
241
- }
242
-
243
- await batch.commit()
244
- })
349
+ await pMap(
350
+ _chunk(ids, MAX_ITEMS),
351
+ async chunk => {
352
+ const batch = firestore.batch()
353
+ for (const id of chunk) {
354
+ batch.delete(col.doc(escapeDocId(id)))
355
+ }
356
+ await batch.commit()
357
+ },
358
+ { concurrency: FIRESTORE_RECOMMENDED_CONCURRENCY },
359
+ )
245
360
 
246
361
  return ids.length
247
362
  }
248
363
 
249
- private querySnapshotToArray<T = any>(qs: QuerySnapshot): T[] {
250
- const rows: any[] = []
364
+ override async multiDeleteByIds(
365
+ map: StringMap<string[]>,
366
+ opt: FirestoreDBOptions = {},
367
+ ): Promise<number> {
368
+ const { firestore } = this.cfg
369
+ const refs: DocumentReference[] = []
370
+ for (const [table, ids] of _stringMapEntries(map)) {
371
+ const col = firestore.collection(table)
372
+ refs.push(...ids.map(id => col.doc(escapeDocId(id))))
373
+ }
251
374
 
252
- qs.forEach(doc => {
253
- rows.push({
254
- id: unescapeDocId(doc.id),
255
- ...doc.data(),
256
- })
257
- })
375
+ if (opt.tx) {
376
+ const { tx } = opt.tx as FirestoreDBTransaction
377
+ for (const ref of refs) {
378
+ tx.delete(ref)
379
+ }
380
+ } else {
381
+ await pMap(
382
+ _chunk(refs, MAX_ITEMS),
383
+ async chunk => {
384
+ const batch = firestore.batch()
385
+ for (const ref of chunk) {
386
+ batch.delete(ref)
387
+ }
388
+ await batch.commit()
389
+ },
390
+ { concurrency: FIRESTORE_RECOMMENDED_CONCURRENCY },
391
+ )
392
+ }
393
+
394
+ return refs.length
395
+ }
258
396
 
259
- return rows
397
+ private querySnapshotToArray<T = any>(qs: QuerySnapshot): T[] {
398
+ return qs.docs.map(
399
+ doc =>
400
+ ({
401
+ id: unescapeDocId(doc.id),
402
+ ...doc.data(),
403
+ }) as T,
404
+ )
260
405
  }
261
406
 
262
407
  override async runInTransaction(
@@ -301,7 +446,6 @@ export class FirestoreDB extends BaseCommonDB implements CommonDB {
301
446
  batch.set(
302
447
  col.doc(escapeDocId(id)),
303
448
  {
304
- // todo: lazy-load FieldValue
305
449
  [prop]: FieldValue.increment(increment),
306
450
  },
307
451
  { merge: true },
@@ -358,3 +502,10 @@ export class FirestoreDBTransaction implements DBTransaction {
358
502
  return await this.db.deleteByIds(table, ids, { ...opt, tx: this })
359
503
  }
360
504
  }
505
+
506
+ // Datastore (also Firestore and other Google APIs) supports max 500 of items when saving/deleting, etc.
507
+ const MAX_ITEMS = 500
508
+ // It's an empyrical value, but anything less than infinity is better than infinity
509
+ const FIRESTORE_RECOMMENDED_CONCURRENCY = 8
510
+
511
+ type TableRow<ROW extends ObjectWithId> = [table: string, row: ROW]
package/src/query.util.ts CHANGED
@@ -13,17 +13,17 @@ export function dbQueryToFirestoreQuery<ROW extends ObjectWithId>(
13
13
  dbQuery: DBQuery<ROW>,
14
14
  emptyQuery: Query,
15
15
  ): Query {
16
+ let q = emptyQuery
17
+
16
18
  // filter
17
- // eslint-disable-next-line unicorn/no-array-reduce
18
- let q = dbQuery._filters.reduce((q, f) => {
19
- return q.where(f.name as string, OP_MAP[f.op] || (f.op as WhereFilterOp), f.val)
20
- }, emptyQuery)
19
+ for (const f of dbQuery._filters) {
20
+ q = q.where(f.name as string, OP_MAP[f.op] || (f.op as WhereFilterOp), f.val)
21
+ }
21
22
 
22
23
  // order
23
- // eslint-disable-next-line unicorn/no-array-reduce
24
- q = dbQuery._orders.reduce((q, ord) => {
25
- return q.orderBy(ord.name as string, ord.descending ? 'desc' : 'asc')
26
- }, q)
24
+ for (const ord of dbQuery._orders) {
25
+ q = q.orderBy(ord.name as string, ord.descending ? 'desc' : 'asc')
26
+ }
27
27
 
28
28
  // limit
29
29
  q = q.limit(dbQuery._limitValue)