@adobe/aio-lib-db 0.1.0-alpha.1

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.
@@ -0,0 +1,350 @@
1
+ /*
2
+ Copyright 2025 Adobe. All rights reserved.
3
+ This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ you may not use this file except in compliance with the License. You may obtain a copy
5
+ of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+
7
+ Unless required by applicable law or agreed to in writing, software distributed under
8
+ the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ OF ANY KIND, either express or implied. See the License for the specific language
10
+ governing permissions and limitations under the License.
11
+ */
12
+ const { Long } = require("bson")
13
+ const { Readable, Transform } = require("node:stream")
14
+ const DbError = require("./DbError")
15
+ const { CURSOR_INIT_ERR_MESSAGE } = require("./constants")
16
+ const { getAxiosClient } = require("../utils/axiosUtils")
17
+ const { getMoreApi } = require("./api/collection")
18
+ const { closeApi } = require("./api/client")
19
+
20
+ /**
21
+ * Abstract base class for database cursors.
22
+ * @abstract
23
+ */
24
+ class AbstractDbCursor extends Readable {
25
+ /** @type boolean */
26
+ #readInProgress
27
+ /** @type function */
28
+ #transform
29
+ /** @type boolean */
30
+ #hasEmittedClose
31
+
32
+ // Protected properties
33
+ /** @type DbBase */
34
+ _db
35
+ /** @type DbClient */
36
+ _dbClient
37
+ /** @type string */
38
+ _collection
39
+ /** @type Object */
40
+ _options
41
+ /** @type Object[] */
42
+ _buffer
43
+ /** @type boolean */
44
+ _sessionClosed
45
+ /** @type {(Long|number)} */
46
+ _id
47
+ /** @type boolean */
48
+ _initialized
49
+ /** @type AxiosInstance */
50
+ _axiosClient
51
+
52
+ /**
53
+ * @param {DbBase} db
54
+ * @param {DbClient} dbClient
55
+ * @param {string} collection
56
+ * @param {Object} options
57
+ */
58
+ constructor(db, dbClient, collection, options) {
59
+ super({ objectMode: true, highWaterMark: 1 })
60
+ this._initialized = false
61
+ this._db = db
62
+ this._dbClient = dbClient
63
+ this._collection = collection
64
+ this._options = options
65
+ this.#readInProgress = false
66
+ this.#transform = null
67
+ this._sessionClosed = false
68
+ this.#hasEmittedClose = false
69
+ this._axiosClient = getAxiosClient(true)
70
+ }
71
+
72
+ /**
73
+ * Checks if there are more results that have not been exhausted yet
74
+ *
75
+ * @returns {Promise<boolean>}
76
+ * @throws {DbError}
77
+ */
78
+ async hasNext() {
79
+ if (!this._initialized) {
80
+ await this.#initialize()
81
+ }
82
+ else if (this._buffer.length === 0 && !this.#isDead) {
83
+ try {
84
+ await this._getMore()
85
+ }
86
+ catch (err) {
87
+ try {
88
+ await this.close()
89
+ }
90
+ catch (swallow) {
91
+ // Throw the original error, not any that happen while killing the cursor in response
92
+ }
93
+ throw err
94
+ }
95
+ }
96
+
97
+ // Automatically close the session if the end of the results has been reached
98
+ if (Long.ZERO.eq(this._id)) {
99
+ await this.close()
100
+ }
101
+
102
+ return this._buffer.length > 0
103
+ }
104
+
105
+ /**
106
+ * Gets and exhausts the next result, or returns null if no results are remaining
107
+ *
108
+ * @returns {Promise<Object|null>}
109
+ * @throws {DbError}
110
+ */
111
+ async next() {
112
+ let res = await this.hasNext() ? this._buffer.shift() : null
113
+ if (res !== null && this.#transform) {
114
+ res = await this.#transform(res)
115
+ }
116
+ return res
117
+ }
118
+
119
+ /**
120
+ * Gets the rest of the results in a single array and exhausts the cursor
121
+ *
122
+ * @returns {Promise<Object[]>}
123
+ * @throws {DbError}
124
+ */
125
+ async toArray() {
126
+ let results = []
127
+ while (await this.hasNext()) {
128
+ results = results.concat(this._buffer)
129
+ this._buffer = []
130
+ }
131
+ if (this.#transform) {
132
+ results = await Promise.all(results.map(doc => this.#transform(doc)))
133
+ }
134
+ return results
135
+ }
136
+
137
+ async _getMore() {
138
+ // getMore does not accept all options that the initial request does
139
+ const getMoreOptions = {}
140
+ if (this._options.batchSize) {
141
+ getMoreOptions.batchSize = this._options.batchSize
142
+ }
143
+ if (this._options.comment) {
144
+ getMoreOptions.comment = this._options.comment
145
+ }
146
+ const data = await getMoreApi(this._db, this._axiosClient, this._collection, this._id, getMoreOptions)
147
+ this._buffer = data.cursor['nextBatch']
148
+ if (Long.ZERO.eq(data.cursor['id'])) {
149
+ this._id = Long.ZERO
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Closes the API session. If the cursor was not dead, also clear the buffer to release resources.
155
+ */
156
+ async close() {
157
+ if (!this._sessionClosed) {
158
+ this._sessionClosed = true
159
+ this._dbClient.unregisterCursor(this)
160
+ // If the session has been closed before the end of results was reached either through an error or explicit close,
161
+ // stop iteration and clear the buffer
162
+ if (this._initialized && this._id && Long.ZERO.ne(this._id)) {
163
+ this._id = Long.ZERO
164
+ this._buffer = []
165
+ }
166
+ try {
167
+ await closeApi(this._db, this._axiosClient)
168
+ }
169
+ finally {
170
+ // Note: the 'close' event signifies that the cursor session is no longer active, regardless of any remaining
171
+ // documents in the buffer. This is in contrast to 'cursor.closed', which waits for the buffer to be consumed.
172
+ // This matches the behavior of the MongoDB Node.js driver.
173
+ if (!this.#hasEmittedClose) {
174
+ this.emit('close')
175
+ }
176
+ }
177
+ }
178
+ }
179
+
180
+ emit(event, ...args) {
181
+ try {
182
+ super.emit(event, ...args)
183
+ }
184
+ finally {
185
+ if (event?.toLowerCase() === 'close') {
186
+ this.#hasEmittedClose = true
187
+ }
188
+ }
189
+ }
190
+
191
+ /**
192
+ * The cursor ID
193
+ * Will be 0 if the results fit in one batch.
194
+ *
195
+ * @const
196
+ * @readonly
197
+ * @returns {Long|number}
198
+ */
199
+ get id() {
200
+ return this._id
201
+ }
202
+
203
+ /**
204
+ * A cursor is closed if all buffered documents have been consumed and no more will be retrieved.
205
+ *
206
+ * @returns {boolean}
207
+ */
208
+ get closed() {
209
+ return this._initialized && this.#isDead && this._buffer?.length === 0
210
+ }
211
+
212
+ /**
213
+ * Checks if the cursor will fetch more results from the API
214
+ *
215
+ * @return {boolean}
216
+ */
217
+ get #isDead() {
218
+ return Long.ZERO.eq(this._id) || this._sessionClosed
219
+ }
220
+
221
+ /**
222
+ * Set the batch size for the cursor.
223
+ *
224
+ * @param {number} batchSize
225
+ * @returns {this}
226
+ */
227
+ batchSize(batchSize) {
228
+ this._throwIfInitialized()
229
+ this._options.batchSize = batchSize
230
+ return this
231
+ }
232
+
233
+ /**
234
+ * Set a transformation function to apply to each document in the cursor.
235
+ *
236
+ * @param {function} fun
237
+ * @returns {this}
238
+ */
239
+ map(fun) {
240
+ this._throwIfInitialized()
241
+ if (this.#transform) {
242
+ const oldTransform = this.#transform
243
+ this.#transform = async (doc) => {
244
+ return fun(await oldTransform(doc))
245
+ }
246
+ }
247
+ else {
248
+ this.#transform = fun
249
+ }
250
+ return this
251
+ }
252
+
253
+ /**
254
+ * Throw an error if iteration has already started.
255
+ *
256
+ * @throws {DbError}
257
+ * @protected
258
+ * @internal
259
+ */
260
+ _throwIfInitialized() {
261
+ if (this._initialized) {
262
+ throw new DbError(CURSOR_INIT_ERR_MESSAGE)
263
+ }
264
+ }
265
+
266
+ /**
267
+ * Initialize the cursor by making the first request to the API.
268
+ * This is called automatically when the first call to hasNext() is made.
269
+ * After initialization, no further modifications to the request can be made.
270
+ *
271
+ * @returns {Promise<void>}
272
+ */
273
+ async #initialize() {
274
+ // Register the cursor as active with the DbClient then make the first request
275
+ this._dbClient.registerCursor(this)
276
+ const firstBatch = await this._firstRequest()
277
+ this._id = firstBatch.cursor.id
278
+ this._buffer = firstBatch.cursor['firstBatch']
279
+ this._initialized = true
280
+ }
281
+
282
+ /**
283
+ * Method to be implemented by subclasses to make the first API request.
284
+ * Should be a single call to apiRequest.apiPost()
285
+ *
286
+ * @returns {Promise<Object>}
287
+ * @protected
288
+ * @internal
289
+ * @abstract
290
+ */
291
+ _firstRequest() {
292
+ throw new SyntaxError('The _firstRequest() method must be implemented by the subclass.')
293
+ }
294
+
295
+ async* [Symbol.asyncIterator]() {
296
+ while (await this.hasNext()) {
297
+ yield await this.next()
298
+ }
299
+ }
300
+
301
+ /**
302
+ * Get a readable stream version of the cursor with a transform if desired
303
+ *
304
+ * @param {{(doc: Object): Object}=} transform A function that takes a document and applies some transformation
305
+ * @returns {Stream.Readable}
306
+ */
307
+ stream(transform = undefined) {
308
+ let readable = Readable.from(this)
309
+ if (transform) {
310
+ const transformedStream = readable.pipe(
311
+ new Transform({
312
+ objectMode: true,
313
+ highWaterMark: 1,
314
+ transform(chunk, _, callback) {
315
+ try {
316
+ const transformed = transform(chunk)
317
+ callback(undefined, transformed)
318
+ }
319
+ catch (err) {
320
+ callback(err)
321
+ }
322
+ }
323
+ })
324
+ )
325
+ readable.on('error', err => transformedStream.emit('error', err))
326
+ readable = transformedStream
327
+ }
328
+
329
+ return readable
330
+ }
331
+
332
+ async _read(size) {
333
+ if (!this.#readInProgress) {
334
+ this.#readInProgress = true
335
+ try {
336
+ if (!(await this.hasNext())) {
337
+ this.push(null)
338
+ return
339
+ }
340
+ this.push(await this.next())
341
+ this.#readInProgress = false
342
+ }
343
+ catch (err) {
344
+ this.destroy(err)
345
+ }
346
+ }
347
+ }
348
+ }
349
+
350
+ module.exports = AbstractDbCursor
@@ -0,0 +1,187 @@
1
+ /*
2
+ Copyright 2025 Adobe. All rights reserved.
3
+ This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ you may not use this file except in compliance with the License. You may obtain a copy
5
+ of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+
7
+ Unless required by applicable law or agreed to in writing, software distributed under
8
+ the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ OF ANY KIND, either express or implied. See the License for the specific language
10
+ governing permissions and limitations under the License.
11
+ */
12
+ const AbstractDbCursor = require("./AbstractDbCursor")
13
+ const { aggregateApi } = require("./api/collection")
14
+
15
+ class AggregateCursor extends AbstractDbCursor {
16
+ #pipeline
17
+
18
+ /**
19
+ * @param {DbBase} db
20
+ * @param {DbClient} client
21
+ * @param {string} collectionName
22
+ * @param {Object[]} pipeline
23
+ * @param {Object} options
24
+ */
25
+ constructor(db, client, collectionName, pipeline, options) {
26
+ super(db, client, collectionName, options)
27
+ this.#pipeline = pipeline
28
+ }
29
+
30
+ /**
31
+ * Get the first batch of results from the database
32
+ *
33
+ * @returns {Promise<Object>}
34
+ * @throws {DbError}
35
+ * @protected
36
+ * @internal
37
+ */
38
+ async _firstRequest() {
39
+ return await aggregateApi(this._db, this._axiosClient, this._collection, this.#pipeline, this._options)
40
+ }
41
+
42
+ /**
43
+ * Explains the query
44
+ *
45
+ * @returns {Object}
46
+ */
47
+ async explain() {
48
+ this._throwIfInitialized()
49
+ this._options.explain = true
50
+ // Explain results don't get iterated on, so we don't need the buffer or session
51
+ const explainResults = await aggregateApi(
52
+ this._db,
53
+ this._db.axiosClient,
54
+ this._collection,
55
+ this.#pipeline,
56
+ this._options
57
+ )
58
+ this._id = 0
59
+ this._sessionClosed = true
60
+ this._buffer = []
61
+ this._initialized = true
62
+ return explainResults
63
+ }
64
+
65
+ /**
66
+ * Add a stage to the aggregation pipeline.
67
+ *
68
+ * @param {Object} stage
69
+ * @returns {this}
70
+ */
71
+ addStage(stage) {
72
+ this._throwIfInitialized()
73
+ this.#pipeline.push(stage)
74
+ return this
75
+ }
76
+
77
+ /**
78
+ * Add a $group stage to the aggregation pipeline.
79
+ *
80
+ * @param {Object} groupSpec
81
+ * @returns {this}
82
+ */
83
+ group(groupSpec) {
84
+ return this.addStage({ $group: groupSpec })
85
+ }
86
+
87
+ /**
88
+ * Add a $limit stage to the aggregation pipeline.
89
+ * @param {number} limit
90
+ * @returns {this}
91
+ */
92
+ limit(limit) {
93
+ return this.addStage({ $limit: limit })
94
+ }
95
+
96
+ /**
97
+ * Add a $match stage to the aggregation pipeline.
98
+ *
99
+ * @param {Object} matchSpec
100
+ * @returns {this}
101
+ */
102
+ match(matchSpec) {
103
+ return this.addStage({ $match: matchSpec })
104
+ }
105
+
106
+ /**
107
+ * Add an $out stage to the aggregation pipeline.
108
+ *
109
+ * @param {(Object|string)} outSpec
110
+ * @returns {this}
111
+ */
112
+ out(outSpec) {
113
+ return this.addStage({ $out: outSpec })
114
+ }
115
+
116
+ /**
117
+ * Add a $project stage to the aggregation pipeline.
118
+ *
119
+ * @param {Object} projectSpec
120
+ * @returns {this}
121
+ */
122
+ project(projectSpec) {
123
+ return this.addStage({ $project: projectSpec })
124
+ }
125
+
126
+ /**
127
+ * Add a $lookup stage to the aggregation pipeline
128
+ *
129
+ * @param {Object}lookupSpec
130
+ * @returns {this}
131
+ */
132
+ lookup(lookupSpec) {
133
+ return this.addStage({ $lookup: lookupSpec })
134
+ }
135
+
136
+ /**
137
+ * Add a $redact stage to the aggregation pipeline
138
+ *
139
+ * @param {Object} redactSpec
140
+ * @returns {this}
141
+ */
142
+ redact(redactSpec) {
143
+ return this.addStage({ $redact: redactSpec })
144
+ }
145
+
146
+ /**
147
+ * Add a $skip stage to the aggregation pipeline.
148
+ *
149
+ * @param {number} skip
150
+ * @returns {this}
151
+ */
152
+ skip(skip) {
153
+ return this.addStage({ $skip: skip })
154
+ }
155
+
156
+ /**
157
+ * Add a $sort stage to the aggregation pipeline
158
+ *
159
+ * @param {(Object|string|number)} sortSpec
160
+ * @returns {this}
161
+ */
162
+ sort(sortSpec) {
163
+ return this.addStage({ $sort: sortSpec })
164
+ }
165
+
166
+ /**
167
+ * Add a $count stage to the aggregation pipeline
168
+ *
169
+ * @param {(Object|string)} unwindSpec
170
+ * @returns {this}
171
+ */
172
+ unwind(unwindSpec) {
173
+ return this.addStage({ $unwind: unwindSpec })
174
+ }
175
+
176
+ /**
177
+ * Add a $geoNear stage to the aggregation pipeline
178
+ *
179
+ * @param {Object} geoNearSpec
180
+ * @returns {this}
181
+ */
182
+ geoNear(geoNearSpec) {
183
+ return this.addStage({ $geoNear: geoNearSpec })
184
+ }
185
+ }
186
+
187
+ module.exports = AggregateCursor
package/lib/DbBase.js ADDED
@@ -0,0 +1,149 @@
1
+ /*
2
+ Copyright 2025 Adobe. All rights reserved.
3
+ This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ you may not use this file except in compliance with the License. You may obtain a copy
5
+ of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+
7
+ Unless required by applicable law or agreed to in writing, software distributed under
8
+ the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ OF ANY KIND, either express or implied. See the License for the specific language
10
+ governing permissions and limitations under the License.
11
+ */
12
+ require('dotenv/config')
13
+ const { pingApi, provisionStatusApi, provisionRequestApi, deleteDatabaseApi } = require('./api/db')
14
+ const DbClient = require('./DbClient')
15
+ const DbError = require("./DbError")
16
+ const { getAxiosClient } = require("../utils/axiosUtils")
17
+ const {
18
+ ALLOWED_REGIONS, STAGE_ENV, STAGE_ENDPOINT, PROD_ENDPOINT_RUNTIME, PROD_ENDPOINT_EXTERNAL
19
+ } = require("./constants")
20
+ const { getCliEnv } = require("@adobe/aio-lib-env")
21
+
22
+ class DbBase {
23
+ /**
24
+ * @param {string} region
25
+ * @param {string} runtimeNamespace
26
+ * @param {string} runtimeAuth
27
+ * @hideconstructor
28
+ */
29
+ constructor(region, runtimeNamespace, runtimeAuth) {
30
+ this.runtimeAuth = runtimeAuth
31
+ if (!this.runtimeAuth) {
32
+ throw new DbError('Runtime auth is required')
33
+ }
34
+ if (!/.+:.+/.test(this.runtimeAuth)) {
35
+ throw new DbError("Invalid format for runtime auth, must be '<user>:<pass>'")
36
+ }
37
+
38
+ this.runtimeNamespace = runtimeNamespace
39
+ if (!this.runtimeNamespace) {
40
+ throw new DbError('Runtime namespace is required')
41
+ }
42
+
43
+ this.region = region.toLowerCase()
44
+ const env = process.env.AIO_DB_ENVIRONMENT || getCliEnv()
45
+ const validRegions = ALLOWED_REGIONS[env]
46
+ if (!validRegions.includes(this.region)) {
47
+ throw new DbError(`Invalid region '${region}' for the ${env} environment, must be one of: ${validRegions.join(', ')}`)
48
+ }
49
+
50
+ let serviceUrl
51
+ // Allow overriding service URL via environment variable for testing
52
+ if (process.env.AIO_DB_ENDPOINT) {
53
+ serviceUrl = process.env.AIO_DB_ENDPOINT
54
+ }
55
+ else {
56
+ if (env === STAGE_ENV) {
57
+ // Stage environment does not have a separate runtime endpoint
58
+ serviceUrl = STAGE_ENDPOINT
59
+ }
60
+ else if (process.env.__OW_ACTIVATION_ID) {
61
+ // If __OW_ACTIVATION_ID is set, the sdk is being used from inside a runtime action
62
+ serviceUrl = PROD_ENDPOINT_RUNTIME
63
+ }
64
+ else {
65
+ serviceUrl = PROD_ENDPOINT_EXTERNAL
66
+ }
67
+ serviceUrl = serviceUrl.replaceAll(/<region>/gi, this.region)
68
+ }
69
+
70
+ this.serviceUrl = serviceUrl
71
+ this.axiosClient = getAxiosClient()
72
+ }
73
+
74
+ /**
75
+ * Instantiates and returns a new DbBase object
76
+ *
77
+ * @static
78
+ * @constructs
79
+ * @param {Object=} config
80
+ * @param {string=} config.namespace required here or as __OW_API_NAMESPACE in .env
81
+ * @param {string=} config.apikey required here or as __OW_API_KEY in .env
82
+ * @param {string=} config.region optional, default is 'amer'. Allowed prod values are 'amer', 'emea', 'apac'
83
+ * @returns {Promise<DbBase>} a new DbBase instance
84
+ * @memberof DbBase
85
+ */
86
+ static async init(config = {}) {
87
+ const namespace = config.namespace || process.env.__OW_NAMESPACE
88
+ const apikey = config.apikey || process.env.__OW_API_KEY
89
+ const region = config.region || ALLOWED_REGIONS[getCliEnv()].at(0)
90
+ return new DbBase(region, namespace, apikey)
91
+ }
92
+
93
+ /**
94
+ * Send a request to provision a database scoped to the configured AIO runtime namespace
95
+ *
96
+ * @returns {Promise<Object>}
97
+ * @throws {DbError}
98
+ * @memberof DbBase
99
+ */
100
+ async provisionRequest() {
101
+ return provisionRequestApi(this)
102
+ }
103
+
104
+ /**
105
+ * Check the provisioning status for the configured AIO runtime namespace
106
+ *
107
+ * @returns {Promise<Object>}
108
+ * @throws {DbError}
109
+ * @memberof DbBase
110
+ */
111
+ async provisionStatus() {
112
+ return provisionStatusApi(this)
113
+ }
114
+
115
+ /**
116
+ * Initialize connection to App Builder Database Service
117
+ *
118
+ * @returns {Promise<DbClient>} a new DbClient instance
119
+ * @throws {DbError}
120
+ * @memberof DbBase
121
+ */
122
+ async connect() {
123
+ return DbClient.init(this)
124
+ }
125
+
126
+ /**
127
+ * General connectivity check with App Builder Database Service
128
+ *
129
+ * @returns {Promise<Object>} ping results
130
+ * @throws {DbError}
131
+ * @memberof DbBase
132
+ */
133
+ async ping() {
134
+ return await pingApi(this)
135
+ }
136
+
137
+ /**
138
+ * Delete the tenant database for the configured AIO runtime namespace
139
+ *
140
+ * @returns {Promise<Object>}
141
+ * @throws {DbError}
142
+ * @memberof DbBase
143
+ */
144
+ async deleteDatabase() {
145
+ return deleteDatabaseApi(this)
146
+ }
147
+ }
148
+
149
+ module.exports = DbBase