@naturalcycles/firestore-lib 2.6.0 → 2.8.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,37 +1,28 @@
1
- import type { Firestore, Query, Transaction } from '@google-cloud/firestore';
2
- import type { CommonDB, CommonDBOptions, CommonDBReadOptions, CommonDBSaveOptions, CommonDBStreamOptions, CommonDBSupport, CommonDBTransactionOptions, DBQuery, DBTransaction, DBTransactionFn, RunQueryResult } from '@naturalcycles/db-lib';
1
+ import type { Firestore, Query, QuerySnapshot, Transaction } from '@google-cloud/firestore';
2
+ import type { CommonDB, CommonDBOptions, CommonDBReadOptions, CommonDBSaveOptions, CommonDBSupport, CommonDBTransactionOptions, DBQuery, DBTransaction, DBTransactionFn, RunQueryResult } from '@naturalcycles/db-lib';
3
3
  import { BaseCommonDB } from '@naturalcycles/db-lib';
4
- import type { ObjectWithId, StringMap } from '@naturalcycles/js-lib/types';
4
+ import { type CommonLogger } from '@naturalcycles/js-lib/log';
5
+ import type { NumberOfSeconds, ObjectWithId, StringMap } from '@naturalcycles/js-lib/types';
5
6
  import type { ReadableTyped } from '@naturalcycles/nodejs-lib/stream';
6
- export interface FirestoreDBCfg {
7
- firestore: Firestore;
8
- }
9
- export interface FirestoreDBOptions extends CommonDBOptions {
10
- }
11
- export interface FirestoreDBReadOptions extends CommonDBReadOptions {
12
- }
13
- export interface FirestoreDBSaveOptions<ROW extends ObjectWithId> extends CommonDBSaveOptions<ROW> {
14
- }
15
- export declare class RollbackError extends Error {
16
- constructor();
17
- }
18
7
  export declare class FirestoreDB extends BaseCommonDB implements CommonDB {
19
- cfg: FirestoreDBCfg;
20
8
  constructor(cfg: FirestoreDBCfg);
9
+ cfg: FirestoreDBCfg & {
10
+ logger: CommonLogger;
11
+ };
21
12
  support: CommonDBSupport;
22
13
  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[]>>;
14
+ multiGet<ROW extends ObjectWithId>(map: StringMap<string[]>, opt?: CommonDBReadOptions): Promise<StringMap<ROW[]>>;
24
15
  runQuery<ROW extends ObjectWithId>(q: DBQuery<ROW>, opt?: FirestoreDBOptions): Promise<RunQueryResult<ROW>>;
25
16
  runFirestoreQuery<ROW extends ObjectWithId>(q: Query): Promise<ROW[]>;
26
17
  runQueryCount<ROW extends ObjectWithId>(q: DBQuery<ROW>, _opt?: FirestoreDBOptions): Promise<number>;
27
- streamQuery<ROW extends ObjectWithId>(q: DBQuery<ROW>, _opt?: CommonDBStreamOptions): ReadableTyped<ROW>;
18
+ streamQuery<ROW extends ObjectWithId>(q: DBQuery<ROW>, opt_?: FirestoreDBStreamOptions): ReadableTyped<ROW>;
28
19
  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>;
20
+ multiSave<ROW extends ObjectWithId>(map: StringMap<ROW[]>, opt?: FirestoreDBSaveOptions<ROW>): Promise<void>;
30
21
  patchById<ROW extends ObjectWithId>(table: string, id: string, patch: Partial<ROW>, opt?: FirestoreDBOptions): Promise<void>;
31
22
  deleteByQuery<ROW extends ObjectWithId>(q: DBQuery<ROW>, opt?: FirestoreDBOptions): Promise<number>;
32
23
  deleteByIds(table: string, ids: string[], opt?: FirestoreDBOptions): Promise<number>;
33
- multiDeleteByIds(map: StringMap<string[]>, opt?: FirestoreDBOptions): Promise<number>;
34
- private querySnapshotToArray;
24
+ multiDelete(map: StringMap<string[]>, opt?: FirestoreDBOptions): Promise<number>;
25
+ querySnapshotToArray<T = any>(qs: QuerySnapshot): T[];
35
26
  runInTransaction(fn: DBTransactionFn, opt?: CommonDBTransactionOptions): Promise<void>;
36
27
  /**
37
28
  * Caveat: it always returns an empty object, not the actual incrementMap.
@@ -53,3 +44,76 @@ export declare class FirestoreDBTransaction implements DBTransaction {
53
44
  saveBatch<ROW extends ObjectWithId>(table: string, rows: ROW[], opt?: CommonDBSaveOptions<ROW>): Promise<void>;
54
45
  deleteByIds(table: string, ids: string[], opt?: CommonDBOptions): Promise<number>;
55
46
  }
47
+ export interface FirestoreDBCfg {
48
+ firestore: Firestore;
49
+ /**
50
+ * Use it to set default options to stream operations,
51
+ * e.g you can globally enable `experimentalCursorStream` here, set the batchSize, etc.
52
+ */
53
+ streamOptions?: FirestoreDBStreamOptions;
54
+ /**
55
+ * Default to `console`
56
+ */
57
+ logger?: CommonLogger;
58
+ }
59
+ export declare class RollbackError extends Error {
60
+ constructor();
61
+ }
62
+ export interface FirestoreDBStreamOptions extends FirestoreDBReadOptions {
63
+ /**
64
+ * Set to `true` to stream via experimental "cursor-query based stream".
65
+ *
66
+ * Defaults to false
67
+ */
68
+ experimentalCursorStream?: boolean;
69
+ /**
70
+ * Applicable to `experimentalCursorStream`.
71
+ * Defines the size (limit) of each individual query.
72
+ *
73
+ * Default: 1000
74
+ */
75
+ batchSize?: number;
76
+ /**
77
+ * Applicable to `experimentalCursorStream`
78
+ *
79
+ * Set to a value (number of Megabytes) to control the peak RSS size.
80
+ * If limit is reached - streaming will pause until the stream keeps up, and then
81
+ * resumes.
82
+ *
83
+ * Set to 0/undefined to disable. Stream will get "slow" then, cause it'll only run the query
84
+ * when _read is called.
85
+ *
86
+ * @default 1000
87
+ */
88
+ rssLimitMB?: number;
89
+ /**
90
+ * Applicable to `experimentalCursorStream`
91
+ * Default false.
92
+ * If true, stream will pause until consumer requests more data (via _read).
93
+ * It means it'll run slower, as buffer will be equal to batchSize (1000) at max.
94
+ * There will be gaps in time between "last query loaded" and "next query requested".
95
+ * This mode is useful e.g for DB migrations, where you want to avoid "stale data".
96
+ * So, it minimizes the time between "item loaded" and "item saved" during DB migration.
97
+ */
98
+ singleBatchBuffer?: boolean;
99
+ /**
100
+ * Set to `true` to log additional debug info, when using experimentalCursorStream.
101
+ *
102
+ * @default false
103
+ */
104
+ debug?: boolean;
105
+ /**
106
+ * Default is undefined.
107
+ * If set - sets a "safety timer", which will force call _read after the specified number of seconds.
108
+ * This is to prevent possible "dead-lock"/race-condition that would make the stream "hang".
109
+ *
110
+ * @experimental
111
+ */
112
+ maxWait?: NumberOfSeconds;
113
+ }
114
+ export interface FirestoreDBOptions extends CommonDBOptions {
115
+ }
116
+ export interface FirestoreDBReadOptions extends CommonDBReadOptions {
117
+ }
118
+ export interface FirestoreDBSaveOptions<ROW extends ObjectWithId> extends CommonDBSaveOptions<ROW> {
119
+ }
@@ -3,27 +3,22 @@ import { BaseCommonDB, commonDBFullSupport } from '@naturalcycles/db-lib';
3
3
  import { _isTruthy } from '@naturalcycles/js-lib';
4
4
  import { _chunk } from '@naturalcycles/js-lib/array/array.util.js';
5
5
  import { _assert } from '@naturalcycles/js-lib/error/assert.js';
6
+ import { commonLoggerMinLevel } from '@naturalcycles/js-lib/log';
6
7
  import { _filterUndefinedValues, _omit } from '@naturalcycles/js-lib/object/object.util.js';
7
8
  import { pMap } from '@naturalcycles/js-lib/promise/pMap.js';
8
9
  import { _stringMapEntries } from '@naturalcycles/js-lib/types';
9
10
  import { escapeDocId, unescapeDocId } from './firestore.util.js';
11
+ import { FirestoreStreamReadable } from './firestoreStreamReadable.js';
10
12
  import { dbQueryToFirestoreQuery } from './query.util.js';
11
- const methodMap = {
12
- insert: 'create',
13
- update: 'update',
14
- upsert: 'set',
15
- };
16
- export class RollbackError extends Error {
17
- constructor() {
18
- super('rollback');
19
- }
20
- }
21
13
  export class FirestoreDB extends BaseCommonDB {
22
- cfg;
23
14
  constructor(cfg) {
24
15
  super();
25
- this.cfg = cfg;
16
+ this.cfg = {
17
+ logger: console,
18
+ ...cfg,
19
+ };
26
20
  }
21
+ cfg;
27
22
  support = {
28
23
  ...commonDBFullSupport,
29
24
  patchByQuery: false, // todo: can be implemented
@@ -33,6 +28,7 @@ export class FirestoreDB extends BaseCommonDB {
33
28
  async getByIds(table, ids, opt = {}) {
34
29
  if (!ids.length)
35
30
  return [];
31
+ // todo: support PITR: https://firebase.google.com/docs/firestore/enterprise/use-pitr#read-pitr
36
32
  const { firestore } = this.cfg;
37
33
  const col = firestore.collection(table);
38
34
  return (await (opt.tx?.tx || firestore).getAll(...ids.map(id => col.doc(escapeDocId(id)))))
@@ -47,7 +43,7 @@ export class FirestoreDB extends BaseCommonDB {
47
43
  })
48
44
  .filter(_isTruthy);
49
45
  }
50
- async multiGetByIds(map, opt = {}) {
46
+ async multiGet(map, opt = {}) {
51
47
  const result = {};
52
48
  const { firestore } = this.cfg;
53
49
  const refs = [];
@@ -95,8 +91,15 @@ export class FirestoreDB extends BaseCommonDB {
95
91
  const r = await firestoreQuery.count().get();
96
92
  return r.data().count;
97
93
  }
98
- streamQuery(q, _opt) {
94
+ streamQuery(q, opt_) {
99
95
  const firestoreQuery = dbQueryToFirestoreQuery(q, this.cfg.firestore.collection(q.table));
96
+ const opt = {
97
+ ...this.cfg.streamOptions,
98
+ ...opt_,
99
+ };
100
+ if (opt.experimentalCursorStream) {
101
+ return new FirestoreStreamReadable(firestoreQuery, q, opt, commonLoggerMinLevel(this.cfg.logger, opt.debug ? 'log' : 'warn'));
102
+ }
100
103
  return firestoreQuery.stream().map(doc => {
101
104
  return {
102
105
  id: unescapeDocId(doc.id),
@@ -132,7 +135,7 @@ export class FirestoreDB extends BaseCommonDB {
132
135
  await batch.commit();
133
136
  }, { concurrency: FIRESTORE_RECOMMENDED_CONCURRENCY });
134
137
  }
135
- async multiSaveBatch(map, opt = {}) {
138
+ async multiSave(map, opt = {}) {
136
139
  const { firestore } = this.cfg;
137
140
  const method = methodMap[opt.saveMethod] || 'set';
138
141
  if (opt.tx) {
@@ -206,7 +209,7 @@ export class FirestoreDB extends BaseCommonDB {
206
209
  }, { concurrency: FIRESTORE_RECOMMENDED_CONCURRENCY });
207
210
  return ids.length;
208
211
  }
209
- async multiDeleteByIds(map, opt = {}) {
212
+ async multiDelete(map, opt = {}) {
210
213
  const { firestore } = this.cfg;
211
214
  const refs = [];
212
215
  for (const [table, ids] of _stringMapEntries(map)) {
@@ -306,3 +309,13 @@ export class FirestoreDBTransaction {
306
309
  const MAX_ITEMS = 500;
307
310
  // It's an empyrical value, but anything less than infinity is better than infinity
308
311
  const FIRESTORE_RECOMMENDED_CONCURRENCY = 8;
312
+ const methodMap = {
313
+ insert: 'create',
314
+ update: 'update',
315
+ upsert: 'set',
316
+ };
317
+ export class RollbackError extends Error {
318
+ constructor() {
319
+ super('rollback');
320
+ }
321
+ }
@@ -0,0 +1,28 @@
1
+ import { Readable } from 'node:stream';
2
+ import { type Query } from '@google-cloud/firestore';
3
+ import type { DBQuery } from '@naturalcycles/db-lib';
4
+ import type { CommonLogger } from '@naturalcycles/js-lib/log';
5
+ import type { ObjectWithId } from '@naturalcycles/js-lib/types';
6
+ import type { ReadableTyped } from '@naturalcycles/nodejs-lib/stream';
7
+ import type { FirestoreDBStreamOptions } from './firestore.db.js';
8
+ export declare class FirestoreStreamReadable<T extends ObjectWithId = any> extends Readable implements ReadableTyped<T> {
9
+ private q;
10
+ private logger;
11
+ private readonly table;
12
+ private readonly originalLimit;
13
+ private rowsRetrieved;
14
+ private endCursor?;
15
+ private running;
16
+ private done;
17
+ private lastQueryDone?;
18
+ private totalWait;
19
+ private readonly opt;
20
+ constructor(q: Query, dbQuery: DBQuery<T>, opt: FirestoreDBStreamOptions, logger: CommonLogger);
21
+ /**
22
+ * Counts how many times _read was called.
23
+ * For debugging.
24
+ */
25
+ count: number;
26
+ _read(): void;
27
+ private runNextQuery;
28
+ }
@@ -0,0 +1,137 @@
1
+ import { Readable } from 'node:stream';
2
+ import { FieldPath } from '@google-cloud/firestore';
3
+ import { _ms } from '@naturalcycles/js-lib/datetime/time.util.js';
4
+ import { pRetry } from '@naturalcycles/js-lib/promise/pRetry.js';
5
+ import { unescapeDocId } from './firestore.util.js';
6
+ export class FirestoreStreamReadable extends Readable {
7
+ q;
8
+ logger;
9
+ table;
10
+ originalLimit;
11
+ rowsRetrieved = 0;
12
+ endCursor;
13
+ running = false;
14
+ done = false;
15
+ lastQueryDone;
16
+ totalWait = 0;
17
+ opt;
18
+ // private readonly dsOpt: RunQueryOptions
19
+ constructor(q, dbQuery, opt, logger) {
20
+ super({ objectMode: true });
21
+ this.q = q;
22
+ this.logger = logger;
23
+ this.opt = {
24
+ rssLimitMB: 1000,
25
+ batchSize: 1000,
26
+ ...opt,
27
+ };
28
+ // todo: support PITR!
29
+ // this.dsOpt = {}
30
+ // if (opt.readAt) {
31
+ // // Datastore expects UnixTimestamp in milliseconds
32
+ // this.dsOpt.readTime = opt.readAt * 1000
33
+ // }
34
+ this.originalLimit = dbQuery._limitValue;
35
+ this.table = dbQuery.table;
36
+ logger.log(`!! using experimentalCursorStream !! ${this.table}, batchSize: ${this.opt.batchSize}`);
37
+ }
38
+ /**
39
+ * Counts how many times _read was called.
40
+ * For debugging.
41
+ */
42
+ count = 0;
43
+ _read() {
44
+ // this.lastReadTimestamp = Date.now() as UnixTimestampMillis
45
+ // console.log(`_read called ${++this.count}, wasRunning: ${this.running}`) // debugging
46
+ this.count++;
47
+ if (this.done) {
48
+ this.logger.warn(`!!! _read was called, but done==true`);
49
+ return;
50
+ }
51
+ if (!this.running) {
52
+ void this.runNextQuery().catch(err => {
53
+ console.log('error in runNextQuery', err);
54
+ this.emit('error', err);
55
+ });
56
+ }
57
+ else {
58
+ this.logger.log(`_read ${this.count}, wasRunning: true`);
59
+ }
60
+ }
61
+ async runNextQuery() {
62
+ if (this.done)
63
+ return;
64
+ if (this.lastQueryDone) {
65
+ const now = Date.now();
66
+ this.totalWait += now - this.lastQueryDone;
67
+ }
68
+ this.running = true;
69
+ let limit = this.opt.batchSize;
70
+ if (this.originalLimit) {
71
+ limit = Math.min(this.opt.batchSize, this.originalLimit - this.rowsRetrieved);
72
+ }
73
+ // console.log(`limit: ${limit}`)
74
+ // We have to orderBy documentId, to be able to use id as a cursor
75
+ let q = this.q.orderBy(FieldPath.documentId()).limit(limit);
76
+ if (this.endCursor) {
77
+ q = q.startAfter(this.endCursor);
78
+ }
79
+ let qs;
80
+ try {
81
+ await pRetry(async () => {
82
+ qs = await q.get();
83
+ }, {
84
+ name: `FirestoreStreamReadable.query(${this.table})`,
85
+ maxAttempts: 5,
86
+ delay: 5000,
87
+ delayMultiplier: 2,
88
+ logger: this.logger,
89
+ timeout: 120_000, // 2 minutes
90
+ });
91
+ }
92
+ catch (err) {
93
+ console.log(`FirestoreStreamReadable error!\n`, {
94
+ table: this.table,
95
+ rowsRetrieved: this.rowsRetrieved,
96
+ }, err);
97
+ this.emit('error', err);
98
+ // clearInterval(this.maxWaitInterval)
99
+ return;
100
+ }
101
+ const rows = [];
102
+ let lastDocId;
103
+ for (const doc of qs.docs) {
104
+ lastDocId = doc.id;
105
+ rows.push({
106
+ id: unescapeDocId(doc.id),
107
+ ...doc.data(),
108
+ });
109
+ }
110
+ this.rowsRetrieved += rows.length;
111
+ this.logger.log(`${this.table} got ${rows.length} rows, ${this.rowsRetrieved} rowsRetrieved, totalWait: ${_ms(this.totalWait)}`);
112
+ this.endCursor = lastDocId;
113
+ this.running = false; // ready to take more _reads
114
+ this.lastQueryDone = Date.now();
115
+ for (const row of rows) {
116
+ this.push(row);
117
+ }
118
+ if (qs.empty || (this.originalLimit && this.rowsRetrieved >= this.originalLimit)) {
119
+ this.logger.log(`!!!! DONE! ${this.rowsRetrieved} rowsRetrieved, totalWait: ${_ms(this.totalWait)}`);
120
+ this.push(null);
121
+ this.done = true;
122
+ }
123
+ else if (this.opt.singleBatchBuffer) {
124
+ // here we don't start next query until we're asked (via next _read call)
125
+ // so, let's do nothing
126
+ }
127
+ else {
128
+ const rssMB = Math.round(process.memoryUsage().rss / 1024 / 1024);
129
+ if (rssMB <= this.opt.rssLimitMB) {
130
+ void this.runNextQuery();
131
+ }
132
+ else {
133
+ this.logger.warn(`${this.table} rssLimitMB reached ${rssMB} > ${this.opt.rssLimitMB}, pausing stream`);
134
+ }
135
+ }
136
+ }
137
+ }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Firestore } from '@google-cloud/firestore';
2
2
  export * from './firestore.db.js';
3
+ export * from './firestoreStreamReadable.js';
3
4
  export * from './query.util.js';
4
5
  export { Firestore };
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Firestore } from '@google-cloud/firestore';
2
2
  export * from './firestore.db.js';
3
+ export * from './firestoreStreamReadable.js';
3
4
  export * from './query.util.js';
4
5
  export { Firestore };
@@ -12,6 +12,8 @@ export function dbQueryToFirestoreQuery(dbQuery, emptyQuery) {
12
12
  }
13
13
  // order
14
14
  for (const ord of dbQuery._orders) {
15
+ // todo: support ordering by id like this:
16
+ // .orderBy(FieldPath.documentId())
15
17
  q = q.orderBy(ord.name, ord.descending ? 'desc' : 'asc');
16
18
  }
17
19
  // limit
@@ -21,5 +23,12 @@ export function dbQueryToFirestoreQuery(dbQuery, emptyQuery) {
21
23
  // todo: check if at least id / __key__ is required to be set
22
24
  q = q.select(...dbQuery._selectedFieldNames);
23
25
  }
26
+ // cursor
27
+ if (dbQuery._startCursor) {
28
+ q = q.startAt(dbQuery._startCursor);
29
+ }
30
+ if (dbQuery._endCursor) {
31
+ q = q.endAt(dbQuery._endCursor);
32
+ }
24
33
  return q;
25
34
  }
package/package.json CHANGED
@@ -12,7 +12,7 @@
12
12
  "@types/node": "^24",
13
13
  "dotenv": "^17",
14
14
  "firebase-admin": "^13",
15
- "@naturalcycles/dev-lib": "18.4.2"
15
+ "@naturalcycles/dev-lib": "19.34.2"
16
16
  },
17
17
  "exports": {
18
18
  ".": "./dist/index.js"
@@ -38,7 +38,7 @@
38
38
  "engines": {
39
39
  "node": ">=22.12.0"
40
40
  },
41
- "version": "2.6.0",
41
+ "version": "2.8.0",
42
42
  "description": "Firestore implementation of CommonDB interface",
43
43
  "author": "Natural Cycles Team",
44
44
  "license": "MIT",
@@ -14,7 +14,6 @@ import type {
14
14
  CommonDBReadOptions,
15
15
  CommonDBSaveMethod,
16
16
  CommonDBSaveOptions,
17
- CommonDBStreamOptions,
18
17
  CommonDBSupport,
19
18
  CommonDBTransactionOptions,
20
19
  DBQuery,
@@ -26,42 +25,27 @@ import { BaseCommonDB, commonDBFullSupport } from '@naturalcycles/db-lib'
26
25
  import { _isTruthy } from '@naturalcycles/js-lib'
27
26
  import { _chunk } from '@naturalcycles/js-lib/array/array.util.js'
28
27
  import { _assert } from '@naturalcycles/js-lib/error/assert.js'
28
+ import { type CommonLogger, commonLoggerMinLevel } from '@naturalcycles/js-lib/log'
29
29
  import { _filterUndefinedValues, _omit } from '@naturalcycles/js-lib/object/object.util.js'
30
30
  import { pMap } from '@naturalcycles/js-lib/promise/pMap.js'
31
- import type { ObjectWithId, StringMap } from '@naturalcycles/js-lib/types'
31
+ import type { NumberOfSeconds, ObjectWithId, StringMap } from '@naturalcycles/js-lib/types'
32
32
  import { _stringMapEntries } from '@naturalcycles/js-lib/types'
33
33
  import type { ReadableTyped } from '@naturalcycles/nodejs-lib/stream'
34
34
  import { escapeDocId, unescapeDocId } from './firestore.util.js'
35
+ import { FirestoreStreamReadable } from './firestoreStreamReadable.js'
35
36
  import { dbQueryToFirestoreQuery } from './query.util.js'
36
37
 
37
- export interface FirestoreDBCfg {
38
- firestore: Firestore
39
- }
40
-
41
- export interface FirestoreDBOptions extends CommonDBOptions {}
42
- export interface FirestoreDBReadOptions extends CommonDBReadOptions {}
43
- export interface FirestoreDBSaveOptions<ROW extends ObjectWithId>
44
- extends CommonDBSaveOptions<ROW> {}
45
-
46
- type SaveOp = 'create' | 'update' | 'set'
47
-
48
- const methodMap: Record<CommonDBSaveMethod, SaveOp> = {
49
- insert: 'create',
50
- update: 'update',
51
- upsert: 'set',
52
- }
53
-
54
- export class RollbackError extends Error {
55
- constructor() {
56
- super('rollback')
57
- }
58
- }
59
-
60
38
  export class FirestoreDB extends BaseCommonDB implements CommonDB {
61
- constructor(public cfg: FirestoreDBCfg) {
39
+ constructor(cfg: FirestoreDBCfg) {
62
40
  super()
41
+ this.cfg = {
42
+ logger: console,
43
+ ...cfg,
44
+ }
63
45
  }
64
46
 
47
+ cfg: FirestoreDBCfg & { logger: CommonLogger }
48
+
65
49
  override support: CommonDBSupport = {
66
50
  ...commonDBFullSupport,
67
51
  patchByQuery: false, // todo: can be implemented
@@ -76,6 +60,8 @@ export class FirestoreDB extends BaseCommonDB implements CommonDB {
76
60
  ): Promise<ROW[]> {
77
61
  if (!ids.length) return []
78
62
 
63
+ // todo: support PITR: https://firebase.google.com/docs/firestore/enterprise/use-pitr#read-pitr
64
+
79
65
  const { firestore } = this.cfg
80
66
  const col = firestore.collection(table)
81
67
 
@@ -95,7 +81,7 @@ export class FirestoreDB extends BaseCommonDB implements CommonDB {
95
81
  .filter(_isTruthy)
96
82
  }
97
83
 
98
- override async multiGetByIds<ROW extends ObjectWithId>(
84
+ override async multiGet<ROW extends ObjectWithId>(
99
85
  map: StringMap<string[]>,
100
86
  opt: CommonDBReadOptions = {},
101
87
  ): Promise<StringMap<ROW[]>> {
@@ -163,10 +149,24 @@ export class FirestoreDB extends BaseCommonDB implements CommonDB {
163
149
 
164
150
  override streamQuery<ROW extends ObjectWithId>(
165
151
  q: DBQuery<ROW>,
166
- _opt?: CommonDBStreamOptions,
152
+ opt_?: FirestoreDBStreamOptions,
167
153
  ): ReadableTyped<ROW> {
168
154
  const firestoreQuery = dbQueryToFirestoreQuery(q, this.cfg.firestore.collection(q.table))
169
155
 
156
+ const opt: FirestoreDBStreamOptions = {
157
+ ...this.cfg.streamOptions,
158
+ ...opt_,
159
+ }
160
+
161
+ if (opt.experimentalCursorStream) {
162
+ return new FirestoreStreamReadable(
163
+ firestoreQuery,
164
+ q,
165
+ opt,
166
+ commonLoggerMinLevel(this.cfg.logger, opt.debug ? 'log' : 'warn'),
167
+ )
168
+ }
169
+
170
170
  return (firestoreQuery.stream() as ReadableTyped<QueryDocumentSnapshot<any>>).map(doc => {
171
171
  return {
172
172
  id: unescapeDocId(doc.id),
@@ -230,7 +230,7 @@ export class FirestoreDB extends BaseCommonDB implements CommonDB {
230
230
  )
231
231
  }
232
232
 
233
- override async multiSaveBatch<ROW extends ObjectWithId>(
233
+ override async multiSave<ROW extends ObjectWithId>(
234
234
  map: StringMap<ROW[]>,
235
235
  opt: FirestoreDBSaveOptions<ROW> = {},
236
236
  ): Promise<void> {
@@ -361,7 +361,7 @@ export class FirestoreDB extends BaseCommonDB implements CommonDB {
361
361
  return ids.length
362
362
  }
363
363
 
364
- override async multiDeleteByIds(
364
+ override async multiDelete(
365
365
  map: StringMap<string[]>,
366
366
  opt: FirestoreDBOptions = {},
367
367
  ): Promise<number> {
@@ -394,7 +394,7 @@ export class FirestoreDB extends BaseCommonDB implements CommonDB {
394
394
  return refs.length
395
395
  }
396
396
 
397
- private querySnapshotToArray<T = any>(qs: QuerySnapshot): T[] {
397
+ querySnapshotToArray<T = any>(qs: QuerySnapshot): T[] {
398
398
  return qs.docs.map(
399
399
  doc =>
400
400
  ({
@@ -509,3 +509,95 @@ const MAX_ITEMS = 500
509
509
  const FIRESTORE_RECOMMENDED_CONCURRENCY = 8
510
510
 
511
511
  type TableRow<ROW extends ObjectWithId> = [table: string, row: ROW]
512
+
513
+ export interface FirestoreDBCfg {
514
+ firestore: Firestore
515
+
516
+ /**
517
+ * Use it to set default options to stream operations,
518
+ * e.g you can globally enable `experimentalCursorStream` here, set the batchSize, etc.
519
+ */
520
+ streamOptions?: FirestoreDBStreamOptions
521
+
522
+ /**
523
+ * Default to `console`
524
+ */
525
+ logger?: CommonLogger
526
+ }
527
+
528
+ const methodMap: Record<CommonDBSaveMethod, SaveOp> = {
529
+ insert: 'create',
530
+ update: 'update',
531
+ upsert: 'set',
532
+ }
533
+
534
+ export class RollbackError extends Error {
535
+ constructor() {
536
+ super('rollback')
537
+ }
538
+ }
539
+
540
+ export interface FirestoreDBStreamOptions extends FirestoreDBReadOptions {
541
+ /**
542
+ * Set to `true` to stream via experimental "cursor-query based stream".
543
+ *
544
+ * Defaults to false
545
+ */
546
+ experimentalCursorStream?: boolean
547
+
548
+ /**
549
+ * Applicable to `experimentalCursorStream`.
550
+ * Defines the size (limit) of each individual query.
551
+ *
552
+ * Default: 1000
553
+ */
554
+ batchSize?: number
555
+
556
+ /**
557
+ * Applicable to `experimentalCursorStream`
558
+ *
559
+ * Set to a value (number of Megabytes) to control the peak RSS size.
560
+ * If limit is reached - streaming will pause until the stream keeps up, and then
561
+ * resumes.
562
+ *
563
+ * Set to 0/undefined to disable. Stream will get "slow" then, cause it'll only run the query
564
+ * when _read is called.
565
+ *
566
+ * @default 1000
567
+ */
568
+ rssLimitMB?: number
569
+
570
+ /**
571
+ * Applicable to `experimentalCursorStream`
572
+ * Default false.
573
+ * If true, stream will pause until consumer requests more data (via _read).
574
+ * It means it'll run slower, as buffer will be equal to batchSize (1000) at max.
575
+ * There will be gaps in time between "last query loaded" and "next query requested".
576
+ * This mode is useful e.g for DB migrations, where you want to avoid "stale data".
577
+ * So, it minimizes the time between "item loaded" and "item saved" during DB migration.
578
+ */
579
+ singleBatchBuffer?: boolean
580
+
581
+ /**
582
+ * Set to `true` to log additional debug info, when using experimentalCursorStream.
583
+ *
584
+ * @default false
585
+ */
586
+ debug?: boolean
587
+
588
+ /**
589
+ * Default is undefined.
590
+ * If set - sets a "safety timer", which will force call _read after the specified number of seconds.
591
+ * This is to prevent possible "dead-lock"/race-condition that would make the stream "hang".
592
+ *
593
+ * @experimental
594
+ */
595
+ maxWait?: NumberOfSeconds
596
+ }
597
+
598
+ export interface FirestoreDBOptions extends CommonDBOptions {}
599
+ export interface FirestoreDBReadOptions extends CommonDBReadOptions {}
600
+ export interface FirestoreDBSaveOptions<ROW extends ObjectWithId>
601
+ extends CommonDBSaveOptions<ROW> {}
602
+
603
+ type SaveOp = 'create' | 'update' | 'set'
@@ -0,0 +1,183 @@
1
+ import { Readable } from 'node:stream'
2
+ import { FieldPath, type Query, type QuerySnapshot } from '@google-cloud/firestore'
3
+ import type { DBQuery } from '@naturalcycles/db-lib'
4
+ import { _ms } from '@naturalcycles/js-lib/datetime/time.util.js'
5
+ import type { CommonLogger } from '@naturalcycles/js-lib/log'
6
+ import { pRetry } from '@naturalcycles/js-lib/promise/pRetry.js'
7
+ import type { ObjectWithId } from '@naturalcycles/js-lib/types'
8
+ import type { ReadableTyped } from '@naturalcycles/nodejs-lib/stream'
9
+ import type { FirestoreDBStreamOptions } from './firestore.db.js'
10
+ import { unescapeDocId } from './firestore.util.js'
11
+
12
+ export class FirestoreStreamReadable<T extends ObjectWithId = any>
13
+ extends Readable
14
+ implements ReadableTyped<T>
15
+ {
16
+ private readonly table: string
17
+ private readonly originalLimit: number
18
+ private rowsRetrieved = 0
19
+ private endCursor?: string
20
+ private running = false
21
+ private done = false
22
+ private lastQueryDone?: number
23
+ private totalWait = 0
24
+
25
+ private readonly opt: FirestoreDBStreamOptions & { batchSize: number; rssLimitMB: number }
26
+ // private readonly dsOpt: RunQueryOptions
27
+
28
+ constructor(
29
+ private q: Query,
30
+ dbQuery: DBQuery<T>,
31
+ opt: FirestoreDBStreamOptions,
32
+ private logger: CommonLogger,
33
+ ) {
34
+ super({ objectMode: true })
35
+
36
+ this.opt = {
37
+ rssLimitMB: 1000,
38
+ batchSize: 1000,
39
+ ...opt,
40
+ }
41
+ // todo: support PITR!
42
+ // this.dsOpt = {}
43
+ // if (opt.readAt) {
44
+ // // Datastore expects UnixTimestamp in milliseconds
45
+ // this.dsOpt.readTime = opt.readAt * 1000
46
+ // }
47
+
48
+ this.originalLimit = dbQuery._limitValue
49
+ this.table = dbQuery.table
50
+
51
+ logger.log(
52
+ `!! using experimentalCursorStream !! ${this.table}, batchSize: ${this.opt.batchSize}`,
53
+ )
54
+ }
55
+
56
+ /**
57
+ * Counts how many times _read was called.
58
+ * For debugging.
59
+ */
60
+ count = 0
61
+
62
+ override _read(): void {
63
+ // this.lastReadTimestamp = Date.now() as UnixTimestampMillis
64
+
65
+ // console.log(`_read called ${++this.count}, wasRunning: ${this.running}`) // debugging
66
+ this.count++
67
+
68
+ if (this.done) {
69
+ this.logger.warn(`!!! _read was called, but done==true`)
70
+ return
71
+ }
72
+
73
+ if (!this.running) {
74
+ void this.runNextQuery().catch(err => {
75
+ console.log('error in runNextQuery', err)
76
+ this.emit('error', err)
77
+ })
78
+ } else {
79
+ this.logger.log(`_read ${this.count}, wasRunning: true`)
80
+ }
81
+ }
82
+
83
+ private async runNextQuery(): Promise<void> {
84
+ if (this.done) return
85
+
86
+ if (this.lastQueryDone) {
87
+ const now = Date.now()
88
+ this.totalWait += now - this.lastQueryDone
89
+ }
90
+
91
+ this.running = true
92
+
93
+ let limit = this.opt.batchSize
94
+
95
+ if (this.originalLimit) {
96
+ limit = Math.min(this.opt.batchSize, this.originalLimit - this.rowsRetrieved)
97
+ }
98
+
99
+ // console.log(`limit: ${limit}`)
100
+ // We have to orderBy documentId, to be able to use id as a cursor
101
+ let q = this.q.orderBy(FieldPath.documentId()).limit(limit)
102
+ if (this.endCursor) {
103
+ q = q.startAfter(this.endCursor)
104
+ }
105
+
106
+ let qs: QuerySnapshot
107
+
108
+ try {
109
+ await pRetry(
110
+ async () => {
111
+ qs = await q.get()
112
+ },
113
+ {
114
+ name: `FirestoreStreamReadable.query(${this.table})`,
115
+ maxAttempts: 5,
116
+ delay: 5000,
117
+ delayMultiplier: 2,
118
+ logger: this.logger,
119
+ timeout: 120_000, // 2 minutes
120
+ },
121
+ )
122
+ } catch (err) {
123
+ console.log(
124
+ `FirestoreStreamReadable error!\n`,
125
+ {
126
+ table: this.table,
127
+ rowsRetrieved: this.rowsRetrieved,
128
+ },
129
+ err,
130
+ )
131
+ this.emit('error', err)
132
+ // clearInterval(this.maxWaitInterval)
133
+ return
134
+ }
135
+
136
+ const rows: T[] = []
137
+ let lastDocId: string | undefined
138
+
139
+ for (const doc of qs!.docs) {
140
+ lastDocId = doc.id
141
+ rows.push({
142
+ id: unescapeDocId(doc.id),
143
+ ...doc.data(),
144
+ } as T)
145
+ }
146
+
147
+ this.rowsRetrieved += rows.length
148
+ this.logger.log(
149
+ `${this.table} got ${rows.length} rows, ${this.rowsRetrieved} rowsRetrieved, totalWait: ${_ms(
150
+ this.totalWait,
151
+ )}`,
152
+ )
153
+
154
+ this.endCursor = lastDocId
155
+ this.running = false // ready to take more _reads
156
+ this.lastQueryDone = Date.now()
157
+
158
+ for (const row of rows) {
159
+ this.push(row)
160
+ }
161
+
162
+ if (qs!.empty || (this.originalLimit && this.rowsRetrieved >= this.originalLimit)) {
163
+ this.logger.log(
164
+ `!!!! DONE! ${this.rowsRetrieved} rowsRetrieved, totalWait: ${_ms(this.totalWait)}`,
165
+ )
166
+ this.push(null)
167
+ this.done = true
168
+ } else if (this.opt.singleBatchBuffer) {
169
+ // here we don't start next query until we're asked (via next _read call)
170
+ // so, let's do nothing
171
+ } else {
172
+ const rssMB = Math.round(process.memoryUsage().rss / 1024 / 1024)
173
+
174
+ if (rssMB <= this.opt.rssLimitMB) {
175
+ void this.runNextQuery()
176
+ } else {
177
+ this.logger.warn(
178
+ `${this.table} rssLimitMB reached ${rssMB} > ${this.opt.rssLimitMB}, pausing stream`,
179
+ )
180
+ }
181
+ }
182
+ }
183
+ }
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Firestore } from '@google-cloud/firestore'
2
2
  export * from './firestore.db.js'
3
+ export * from './firestoreStreamReadable.js'
3
4
  export * from './query.util.js'
4
5
  export { Firestore }
package/src/query.util.ts CHANGED
@@ -22,6 +22,8 @@ export function dbQueryToFirestoreQuery<ROW extends ObjectWithId>(
22
22
 
23
23
  // order
24
24
  for (const ord of dbQuery._orders) {
25
+ // todo: support ordering by id like this:
26
+ // .orderBy(FieldPath.documentId())
25
27
  q = q.orderBy(ord.name as string, ord.descending ? 'desc' : 'asc')
26
28
  }
27
29
 
@@ -34,5 +36,14 @@ export function dbQueryToFirestoreQuery<ROW extends ObjectWithId>(
34
36
  q = q.select(...(dbQuery._selectedFieldNames as string[]))
35
37
  }
36
38
 
39
+ // cursor
40
+ if (dbQuery._startCursor) {
41
+ q = q.startAt(dbQuery._startCursor)
42
+ }
43
+
44
+ if (dbQuery._endCursor) {
45
+ q = q.endAt(dbQuery._endCursor)
46
+ }
47
+
37
48
  return q
38
49
  }