@naturalcycles/firestore-lib 2.5.0 → 2.7.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
+ multiGet<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
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
+ multiSave<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
+ multiDelete(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 multiGet(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');
@@ -96,6 +119,10 @@ export class FirestoreDB extends BaseCommonDB {
96
119
  return;
97
120
  }
98
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`);
@@ -105,6 +132,47 @@ export class FirestoreDB extends BaseCommonDB {
105
132
  await batch.commit();
106
133
  }, { concurrency: FIRESTORE_RECOMMENDED_CONCURRENCY });
107
134
  }
135
+ async multiSave(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));
162
+ }
163
+ await batch.commit();
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);
175
+ }
108
176
  // DELETE
109
177
  async deleteByQuery(q, opt) {
110
178
  let ids;
@@ -135,11 +203,33 @@ export class FirestoreDB extends BaseCommonDB {
135
203
  batch.delete(col.doc(escapeDocId(id)));
136
204
  }
137
205
  await batch.commit();
138
- }, {
139
- concurrency: FIRESTORE_RECOMMENDED_CONCURRENCY,
140
- });
206
+ }, { concurrency: FIRESTORE_RECOMMENDED_CONCURRENCY });
141
207
  return ids.length;
142
208
  }
209
+ async multiDelete(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
+ }
143
233
  querySnapshotToArray(qs) {
144
234
  return qs.docs.map(doc => ({
145
235
  id: unescapeDocId(doc.id),
package/package.json CHANGED
@@ -38,7 +38,7 @@
38
38
  "engines": {
39
39
  "node": ">=22.12.0"
40
40
  },
41
- "version": "2.5.0",
41
+ "version": "2.7.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
 
@@ -91,6 +95,34 @@ export class FirestoreDB extends BaseCommonDB implements CommonDB {
91
95
  .filter(_isTruthy)
92
96
  }
93
97
 
98
+ override async multiGet<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>,
@@ -174,6 +206,10 @@ export class FirestoreDB extends BaseCommonDB implements CommonDB {
174
206
  await pMap(
175
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) {
@@ -194,6 +230,82 @@ export class FirestoreDB extends BaseCommonDB implements CommonDB {
194
230
  )
195
231
  }
196
232
 
233
+ override async multiSave<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
279
+ batch[method as 'set' | 'create'](
280
+ firestore.collection(table).doc(escapeDocId(id)),
281
+ _filterUndefinedValues(rowWithoutId),
282
+ )
283
+ }
284
+
285
+ await batch.commit()
286
+ },
287
+ { concurrency: FIRESTORE_RECOMMENDED_CONCURRENCY },
288
+ )
289
+ }
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
+
197
309
  // DELETE
198
310
  override async deleteByQuery<ROW extends ObjectWithId>(
199
311
  q: DBQuery<ROW>,
@@ -238,21 +350,50 @@ export class FirestoreDB extends BaseCommonDB implements CommonDB {
238
350
  _chunk(ids, MAX_ITEMS),
239
351
  async chunk => {
240
352
  const batch = firestore.batch()
241
-
242
353
  for (const id of chunk) {
243
354
  batch.delete(col.doc(escapeDocId(id)))
244
355
  }
245
-
246
356
  await batch.commit()
247
357
  },
248
- {
249
- concurrency: FIRESTORE_RECOMMENDED_CONCURRENCY,
250
- },
358
+ { concurrency: FIRESTORE_RECOMMENDED_CONCURRENCY },
251
359
  )
252
360
 
253
361
  return ids.length
254
362
  }
255
363
 
364
+ override async multiDelete(
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
+ }
374
+
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
+ }
396
+
256
397
  private querySnapshotToArray<T = any>(qs: QuerySnapshot): T[] {
257
398
  return qs.docs.map(
258
399
  doc =>
@@ -366,3 +507,5 @@ export class FirestoreDBTransaction implements DBTransaction {
366
507
  const MAX_ITEMS = 500
367
508
  // It's an empyrical value, but anything less than infinity is better than infinity
368
509
  const FIRESTORE_RECOMMENDED_CONCURRENCY = 8
510
+
511
+ type TableRow<ROW extends ObjectWithId> = [table: string, row: ROW]