@naturalcycles/firestore-lib 2.14.4 → 2.15.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.
@@ -12,9 +12,9 @@ export declare class FirestoreDB extends BaseCommonDB implements CommonDB {
12
12
  support: CommonDBSupport;
13
13
  getByIds<ROW extends ObjectWithId>(table: string, ids: string[], opt?: FirestoreDBReadOptions): Promise<ROW[]>;
14
14
  multiGet<ROW extends ObjectWithId>(map: StringMap<string[]>, opt?: CommonDBReadOptions): Promise<StringMap<ROW[]>>;
15
- runQuery<ROW extends ObjectWithId>(q: DBQuery<ROW>, opt?: FirestoreDBOptions): Promise<RunQueryResult<ROW>>;
16
- runFirestoreQuery<ROW extends ObjectWithId>(q: Query): Promise<ROW[]>;
17
- runQueryCount<ROW extends ObjectWithId>(q: DBQuery<ROW>, _opt?: FirestoreDBOptions): Promise<number>;
15
+ runQuery<ROW extends ObjectWithId>(q: DBQuery<ROW>, opt?: FirestoreDBReadOptions): Promise<RunQueryResult<ROW>>;
16
+ runFirestoreQuery<ROW extends ObjectWithId>(q: Query, opt?: FirestoreDBReadOptions): Promise<ROW[]>;
17
+ runQueryCount<ROW extends ObjectWithId>(q: DBQuery<ROW>, opt?: FirestoreDBReadOptions): Promise<number>;
18
18
  streamQuery<ROW extends ObjectWithId>(q: DBQuery<ROW>, opt_?: FirestoreDBStreamOptions): Pipeline<ROW>;
19
19
  saveBatch<ROW extends ObjectWithId>(table: string, rows: ROW[], opt?: FirestoreDBSaveOptions<ROW>): Promise<void>;
20
20
  multiSave<ROW extends ObjectWithId>(map: StringMap<ROW[]>, opt?: FirestoreDBSaveOptions<ROW>): Promise<void>;
@@ -10,7 +10,7 @@ import { Pipeline } from '@naturalcycles/nodejs-lib/stream';
10
10
  import { escapeDocId, unescapeDocId } from './firestore.util.js';
11
11
  import { FirestoreShardedReadable } from './firestoreShardedReadable.js';
12
12
  import { FirestoreStreamReadable } from './firestoreStreamReadable.js';
13
- import { dbQueryToFirestoreQuery } from './query.util.js';
13
+ import { dbQueryToFirestoreQuery, readAtToReadTime } from './query.util.js';
14
14
  export class FirestoreDB extends BaseCommonDB {
15
15
  constructor(cfg) {
16
16
  super();
@@ -30,10 +30,17 @@ export class FirestoreDB extends BaseCommonDB {
30
30
  async getByIds(table, ids, opt = {}) {
31
31
  if (!ids.length)
32
32
  return [];
33
- // todo: support PITR: https://firebase.google.com/docs/firestore/enterprise/use-pitr#read-pitr
34
33
  const { firestore } = this.cfg;
35
34
  const col = firestore.collection(table);
36
- return (await (opt.tx?.tx || firestore).getAll(...ids.map(id => col.doc(escapeDocId(id)))))
35
+ const refs = ids.map(id => col.doc(escapeDocId(id)));
36
+ const readTime = readAtToReadTime(opt);
37
+ if (readTime) {
38
+ _assert(!opt.tx, 'readAt is not supported inside an existing transaction');
39
+ }
40
+ const snapshots = await (readTime
41
+ ? firestore.runTransaction(tx => tx.getAll(...refs), { readOnly: true, readTime })
42
+ : (opt.tx?.tx || firestore).getAll(...refs));
43
+ return snapshots
37
44
  .map(doc => {
38
45
  const data = doc.data();
39
46
  if (data === undefined)
@@ -54,7 +61,13 @@ export class FirestoreDB extends BaseCommonDB {
54
61
  const col = firestore.collection(table);
55
62
  refs.push(...ids.map(id => col.doc(escapeDocId(id))));
56
63
  }
57
- const snapshots = await (opt.tx?.tx || firestore).getAll(...refs);
64
+ const readTime = readAtToReadTime(opt);
65
+ if (readTime) {
66
+ _assert(!opt.tx, 'readAt is not supported inside an existing transaction');
67
+ }
68
+ const snapshots = await (readTime
69
+ ? firestore.runTransaction(tx => tx.getAll(...refs), { readOnly: true, readTime })
70
+ : (opt.tx?.tx || firestore).getAll(...refs));
58
71
  snapshots.forEach(snap => {
59
72
  const data = snap.data();
60
73
  if (data === undefined)
@@ -69,7 +82,7 @@ export class FirestoreDB extends BaseCommonDB {
69
82
  return result;
70
83
  }
71
84
  // QUERY
72
- async runQuery(q, opt) {
85
+ async runQuery(q, opt = {}) {
73
86
  const idFilter = q._filters.find(f => f.name === 'id');
74
87
  if (idFilter) {
75
88
  const ids = Array.isArray(idFilter.val) ? idFilter.val : [idFilter.val];
@@ -78,18 +91,34 @@ export class FirestoreDB extends BaseCommonDB {
78
91
  };
79
92
  }
80
93
  const firestoreQuery = dbQueryToFirestoreQuery(q, this.cfg.firestore.collection(q.table));
81
- let rows = await this.runFirestoreQuery(firestoreQuery);
94
+ let rows = await this.runFirestoreQuery(firestoreQuery, opt);
82
95
  // Special case when projection query didn't specify 'id'
83
96
  if (q._selectedFieldNames && !q._selectedFieldNames.includes('id')) {
84
97
  rows = rows.map(r => _omit(r, ['id']));
85
98
  }
86
99
  return { rows };
87
100
  }
88
- async runFirestoreQuery(q) {
101
+ async runFirestoreQuery(q, opt = {}) {
102
+ const readTime = readAtToReadTime(opt);
103
+ if (readTime) {
104
+ const qs = await this.cfg.firestore.runTransaction(tx => tx.get(q), {
105
+ readOnly: true,
106
+ readTime,
107
+ });
108
+ return this.querySnapshotToArray(qs);
109
+ }
89
110
  return this.querySnapshotToArray(await q.get());
90
111
  }
91
- async runQueryCount(q, _opt) {
112
+ async runQueryCount(q, opt = {}) {
92
113
  const firestoreQuery = dbQueryToFirestoreQuery(q, this.cfg.firestore.collection(q.table));
114
+ const readTime = readAtToReadTime(opt);
115
+ if (readTime) {
116
+ const r = await this.cfg.firestore.runTransaction(tx => tx.get(firestoreQuery.count()), {
117
+ readOnly: true,
118
+ readTime,
119
+ });
120
+ return r.data().count;
121
+ }
93
122
  const r = await firestoreQuery.count().get();
94
123
  return r.data().count;
95
124
  }
@@ -101,7 +130,7 @@ export class FirestoreDB extends BaseCommonDB {
101
130
  ...this.cfg.streamOptions,
102
131
  ...opt_,
103
132
  };
104
- if (opt.experimentalCursorStream) {
133
+ if (opt.experimentalCursorStream || opt.readAt) {
105
134
  return Pipeline.from(new FirestoreStreamReadable(firestoreQuery, q, opt));
106
135
  }
107
136
  if (opt.experimentalShardedStream) {
@@ -18,6 +18,7 @@ export declare class FirestoreStreamReadable<T extends ObjectWithId = any> exten
18
18
  * For debugging.
19
19
  */
20
20
  countReads: number;
21
+ private readonly readTime?;
21
22
  private readonly opt;
22
23
  private logger;
23
24
  constructor(q: Query, dbQuery: DBQuery<T>, opt: FirestoreDBStreamOptions);
@@ -6,6 +6,7 @@ import { TimeoutError } from '@naturalcycles/js-lib/error/error.util.js';
6
6
  import { createCommonLoggerAtLevel } from '@naturalcycles/js-lib/log';
7
7
  import { pRetry } from '@naturalcycles/js-lib/promise/pRetry.js';
8
8
  import { unescapeDocId } from './firestore.util.js';
9
+ import { readAtToReadTime } from './query.util.js';
9
10
  export class FirestoreStreamReadable extends Readable {
10
11
  q;
11
12
  table;
@@ -20,6 +21,7 @@ export class FirestoreStreamReadable extends Readable {
20
21
  * For debugging.
21
22
  */
22
23
  countReads = 0;
24
+ readTime;
23
25
  opt;
24
26
  logger;
25
27
  constructor(q, dbQuery, opt) {
@@ -34,7 +36,7 @@ export class FirestoreStreamReadable extends Readable {
34
36
  batchSize,
35
37
  highWaterMark,
36
38
  };
37
- // todo: support PITR!
39
+ this.readTime = readAtToReadTime(opt);
38
40
  this.originalLimit = dbQuery._limitValue;
39
41
  this.table = dbQuery.table;
40
42
  const logger = createCommonLoggerAtLevel(opt.logger, opt.logLevel);
@@ -135,6 +137,12 @@ export class FirestoreStreamReadable extends Readable {
135
137
  const { table, logger } = this;
136
138
  try {
137
139
  return await pRetry(async () => {
140
+ if (this.readTime) {
141
+ return await this.q.firestore.runTransaction(tx => tx.get(q), {
142
+ readOnly: true,
143
+ readTime: this.readTime,
144
+ });
145
+ }
138
146
  return await q.get();
139
147
  }, {
140
148
  name: `FirestoreStreamReadable.query(${table})`,
@@ -1,4 +1,6 @@
1
+ import { Timestamp } from '@google-cloud/firestore';
1
2
  import type { Query } from '@google-cloud/firestore';
2
- import type { DBQuery } from '@naturalcycles/db-lib';
3
+ import type { CommonDBReadOptions, DBQuery } from '@naturalcycles/db-lib';
3
4
  import type { ObjectWithId } from '@naturalcycles/js-lib/types';
5
+ export declare function readAtToReadTime(opt: CommonDBReadOptions): Timestamp | undefined;
4
6
  export declare function dbQueryToFirestoreQuery<ROW extends ObjectWithId>(dbQuery: DBQuery<ROW>, emptyQuery: Query): Query;
@@ -1,4 +1,15 @@
1
- import { FieldPath } from '@google-cloud/firestore';
1
+ import { FieldPath, Timestamp } from '@google-cloud/firestore';
2
+ import { _round } from '@naturalcycles/js-lib';
3
+ export function readAtToReadTime(opt) {
4
+ if (!opt.readAt)
5
+ return;
6
+ // Same logic as Datastore: round to whole minutes, guard against future
7
+ let readTimeMs = _round(opt.readAt, 60) * 1000;
8
+ if (readTimeMs >= Date.now() - 1000) {
9
+ readTimeMs -= 60_000;
10
+ }
11
+ return Timestamp.fromMillis(readTimeMs);
12
+ }
2
13
  // Map DBQueryFilterOp to WhereFilterOp
3
14
  // Currently it's fully aligned!
4
15
  const OP_MAP = {};
package/package.json CHANGED
@@ -10,8 +10,7 @@
10
10
  },
11
11
  "devDependencies": {
12
12
  "@types/node": "^25",
13
- "@typescript/native-preview": "7.0.0-dev.20260201.1",
14
- "dotenv": "^17",
13
+ "@typescript/native-preview": "7.0.0-dev.20260301.1",
15
14
  "firebase-admin": "^13",
16
15
  "@naturalcycles/dev-lib": "18.4.2"
17
16
  },
@@ -39,7 +38,7 @@
39
38
  "engines": {
40
39
  "node": ">=24.10.0"
41
40
  },
42
- "version": "2.14.4",
41
+ "version": "2.15.0",
43
42
  "description": "Firestore implementation of CommonDB interface",
44
43
  "author": "Natural Cycles Team",
45
44
  "license": "MIT",
@@ -35,7 +35,7 @@ import type { ReadableTyped } from '@naturalcycles/nodejs-lib/stream'
35
35
  import { escapeDocId, unescapeDocId } from './firestore.util.js'
36
36
  import { FirestoreShardedReadable } from './firestoreShardedReadable.js'
37
37
  import { FirestoreStreamReadable } from './firestoreStreamReadable.js'
38
- import { dbQueryToFirestoreQuery } from './query.util.js'
38
+ import { dbQueryToFirestoreQuery, readAtToReadTime } from './query.util.js'
39
39
 
40
40
  export class FirestoreDB extends BaseCommonDB implements CommonDB {
41
41
  constructor(cfg: FirestoreDBCfg) {
@@ -63,16 +63,20 @@ export class FirestoreDB extends BaseCommonDB implements CommonDB {
63
63
  ): Promise<ROW[]> {
64
64
  if (!ids.length) return []
65
65
 
66
- // todo: support PITR: https://firebase.google.com/docs/firestore/enterprise/use-pitr#read-pitr
67
-
68
66
  const { firestore } = this.cfg
69
67
  const col = firestore.collection(table)
68
+ const refs = ids.map(id => col.doc(escapeDocId(id)))
70
69
 
71
- return (
72
- await ((opt.tx as FirestoreDBTransaction)?.tx || firestore).getAll(
73
- ...ids.map(id => col.doc(escapeDocId(id))),
74
- )
75
- )
70
+ const readTime = readAtToReadTime(opt)
71
+ if (readTime) {
72
+ _assert(!opt.tx, 'readAt is not supported inside an existing transaction')
73
+ }
74
+
75
+ const snapshots = await (readTime
76
+ ? firestore.runTransaction(tx => tx.getAll(...refs), { readOnly: true, readTime })
77
+ : ((opt.tx as FirestoreDBTransaction)?.tx || firestore).getAll(...refs))
78
+
79
+ return snapshots
76
80
  .map(doc => {
77
81
  const data = doc.data()
78
82
  if (data === undefined) return
@@ -97,7 +101,15 @@ export class FirestoreDB extends BaseCommonDB implements CommonDB {
97
101
  refs.push(...ids.map(id => col.doc(escapeDocId(id))))
98
102
  }
99
103
 
100
- const snapshots = await ((opt.tx as FirestoreDBTransaction)?.tx || firestore).getAll(...refs)
104
+ const readTime = readAtToReadTime(opt)
105
+ if (readTime) {
106
+ _assert(!opt.tx, 'readAt is not supported inside an existing transaction')
107
+ }
108
+
109
+ const snapshots = await (readTime
110
+ ? firestore.runTransaction(tx => tx.getAll(...refs), { readOnly: true, readTime })
111
+ : ((opt.tx as FirestoreDBTransaction)?.tx || firestore).getAll(...refs))
112
+
101
113
  snapshots.forEach(snap => {
102
114
  const data = snap.data()
103
115
  if (data === undefined) return
@@ -115,7 +127,7 @@ export class FirestoreDB extends BaseCommonDB implements CommonDB {
115
127
  // QUERY
116
128
  override async runQuery<ROW extends ObjectWithId>(
117
129
  q: DBQuery<ROW>,
118
- opt?: FirestoreDBOptions,
130
+ opt: FirestoreDBReadOptions = {},
119
131
  ): Promise<RunQueryResult<ROW>> {
120
132
  const idFilter = q._filters.find(f => f.name === 'id')
121
133
  if (idFilter) {
@@ -127,7 +139,7 @@ export class FirestoreDB extends BaseCommonDB implements CommonDB {
127
139
 
128
140
  const firestoreQuery = dbQueryToFirestoreQuery(q, this.cfg.firestore.collection(q.table))
129
141
 
130
- let rows = await this.runFirestoreQuery<ROW>(firestoreQuery)
142
+ let rows = await this.runFirestoreQuery<ROW>(firestoreQuery, opt)
131
143
 
132
144
  // Special case when projection query didn't specify 'id'
133
145
  if (q._selectedFieldNames && !q._selectedFieldNames.includes('id')) {
@@ -137,15 +149,36 @@ export class FirestoreDB extends BaseCommonDB implements CommonDB {
137
149
  return { rows }
138
150
  }
139
151
 
140
- async runFirestoreQuery<ROW extends ObjectWithId>(q: Query): Promise<ROW[]> {
152
+ async runFirestoreQuery<ROW extends ObjectWithId>(
153
+ q: Query,
154
+ opt: FirestoreDBReadOptions = {},
155
+ ): Promise<ROW[]> {
156
+ const readTime = readAtToReadTime(opt)
157
+ if (readTime) {
158
+ const qs = await this.cfg.firestore.runTransaction(tx => tx.get(q), {
159
+ readOnly: true,
160
+ readTime,
161
+ })
162
+ return this.querySnapshotToArray(qs)
163
+ }
141
164
  return this.querySnapshotToArray(await q.get())
142
165
  }
143
166
 
144
167
  override async runQueryCount<ROW extends ObjectWithId>(
145
168
  q: DBQuery<ROW>,
146
- _opt?: FirestoreDBOptions,
169
+ opt: FirestoreDBReadOptions = {},
147
170
  ): Promise<number> {
148
171
  const firestoreQuery = dbQueryToFirestoreQuery(q, this.cfg.firestore.collection(q.table))
172
+
173
+ const readTime = readAtToReadTime(opt)
174
+ if (readTime) {
175
+ const r = await this.cfg.firestore.runTransaction(tx => tx.get(firestoreQuery.count()), {
176
+ readOnly: true,
177
+ readTime,
178
+ })
179
+ return r.data().count
180
+ }
181
+
149
182
  const r = await firestoreQuery.count().get()
150
183
  return r.data().count
151
184
  }
@@ -163,7 +196,7 @@ export class FirestoreDB extends BaseCommonDB implements CommonDB {
163
196
  ...opt_,
164
197
  }
165
198
 
166
- if (opt.experimentalCursorStream) {
199
+ if (opt.experimentalCursorStream || opt.readAt) {
167
200
  return Pipeline.from(new FirestoreStreamReadable(firestoreQuery, q, opt))
168
201
  }
169
202
 
@@ -1,6 +1,11 @@
1
1
  import { Readable } from 'node:stream'
2
2
  import { FieldPath } from '@google-cloud/firestore'
3
- import type { Query, QueryDocumentSnapshot, QuerySnapshot } from '@google-cloud/firestore'
3
+ import type {
4
+ Query,
5
+ QueryDocumentSnapshot,
6
+ QuerySnapshot,
7
+ Timestamp,
8
+ } from '@google-cloud/firestore'
4
9
  import type { DBQuery } from '@naturalcycles/db-lib'
5
10
  import { localTime } from '@naturalcycles/js-lib/datetime/localTime.js'
6
11
  import { _ms } from '@naturalcycles/js-lib/datetime/time.util.js'
@@ -12,6 +17,7 @@ import type { ObjectWithId } from '@naturalcycles/js-lib/types'
12
17
  import type { ReadableTyped } from '@naturalcycles/nodejs-lib/stream'
13
18
  import type { FirestoreDBStreamOptions } from './firestore.db.js'
14
19
  import { unescapeDocId } from './firestore.util.js'
20
+ import { readAtToReadTime } from './query.util.js'
15
21
 
16
22
  export class FirestoreStreamReadable<T extends ObjectWithId = any>
17
23
  extends Readable
@@ -30,6 +36,7 @@ export class FirestoreStreamReadable<T extends ObjectWithId = any>
30
36
  */
31
37
  countReads = 0
32
38
 
39
+ private readonly readTime?: Timestamp
33
40
  private readonly opt: FirestoreDBStreamOptions & { batchSize: number; highWaterMark: number }
34
41
  private logger: CommonLogger
35
42
 
@@ -49,7 +56,7 @@ export class FirestoreStreamReadable<T extends ObjectWithId = any>
49
56
  batchSize,
50
57
  highWaterMark,
51
58
  }
52
- // todo: support PITR!
59
+ this.readTime = readAtToReadTime(opt)
53
60
 
54
61
  this.originalLimit = dbQuery._limitValue
55
62
  this.table = dbQuery.table
@@ -176,6 +183,12 @@ export class FirestoreStreamReadable<T extends ObjectWithId = any>
176
183
  try {
177
184
  return await pRetry(
178
185
  async () => {
186
+ if (this.readTime) {
187
+ return await this.q.firestore.runTransaction(tx => tx.get(q), {
188
+ readOnly: true,
189
+ readTime: this.readTime,
190
+ })
191
+ }
179
192
  return await q.get()
180
193
  },
181
194
  {
package/src/query.util.ts CHANGED
@@ -1,8 +1,21 @@
1
- import { FieldPath } from '@google-cloud/firestore'
1
+ import { FieldPath, Timestamp } from '@google-cloud/firestore'
2
2
  import type { Query, WhereFilterOp } from '@google-cloud/firestore'
3
- import type { DBQuery, DBQueryFilterOperator } from '@naturalcycles/db-lib'
3
+ import type { CommonDBReadOptions, DBQuery, DBQueryFilterOperator } from '@naturalcycles/db-lib'
4
+ import { _round } from '@naturalcycles/js-lib'
4
5
  import type { ObjectWithId } from '@naturalcycles/js-lib/types'
5
6
 
7
+ export function readAtToReadTime(opt: CommonDBReadOptions): Timestamp | undefined {
8
+ if (!opt.readAt) return
9
+
10
+ // Same logic as Datastore: round to whole minutes, guard against future
11
+ let readTimeMs = _round(opt.readAt, 60) * 1000
12
+ if (readTimeMs >= Date.now() - 1000) {
13
+ readTimeMs -= 60_000
14
+ }
15
+
16
+ return Timestamp.fromMillis(readTimeMs)
17
+ }
18
+
6
19
  // Map DBQueryFilterOp to WhereFilterOp
7
20
  // Currently it's fully aligned!
8
21
  const OP_MAP: Partial<Record<DBQueryFilterOperator, WhereFilterOp>> = {