@adobe/aio-lib-db 0.2.0-beta.1 → 1.0.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.
package/README.md CHANGED
@@ -12,595 +12,11 @@ Install `aio-lib-db` from npm:
12
12
  npm install @adobe/aio-lib-db
13
13
  ```
14
14
 
15
- ---
16
-
17
- ## Quick Start
18
-
19
- ### Setup
20
-
21
- **aio-lib-db** is intended to be used by AIO Runtime Actions and the DB Plugin for the AIO CLI, and these are always executed within a specific runtime namespace. Before use, a Workspace Database must be provisioned. (See [Provisioning a Workspace Database](https://developer.adobe.com/app-builder/docs/guides/app_builder_guides/storage/database#provisioning-a-workspace-database) in the [Getting Started with Database Storage](https://developer.adobe.com/app-builder/docs/guides/app_builder_guides/storage/database) guide for details.)
22
-
23
- **aio-lib-db** must be initialized in the region the workspace database was provisioned. Otherwise, the connection will fail. To explicitly initialize the library in a specific region, pass the `{region: "<region>"}` argument to the `libDb.init()` method. Called with no arguments, `libDb.init()` will initialize the library either in the default `amer` region or in the region defined in the `AIO_DB_REGION` environment variable.
24
-
25
- **aio-lib-db** requires an IMS access token for authentication. Generate the token using `@adobe/aio-sdk` and pass the `{token : "<token>"}` argument to the `libDb.init()` method.
26
-
27
- ```bash
28
- npm install @adobe/aio-sdk --save
29
- ```
30
-
31
- To Add IMS credentials in your Runtime action parameter, set the action annotation `include-ims-credentials: true` in AIO App `app.config.yaml` file.
32
-
33
- ```yaml
34
- actions:
35
- action:
36
- function: actions/generic/action.js
37
- annotations:
38
- include-ims-credentials: true
39
- require-adobe-auth: true
40
- final: true
41
- ```
42
-
43
- > [!IMPORTANT]
44
- > Add **App Builder Data Services** to your project to add the required database scopes (`adobeio.abdata.write`, `adobeio.abdata.read`, `adobeio.abdata.manage`). (See [APIs and Services](https://developer.adobe.com/developer-console/docs/guides/apis-and-services) in the [Getting Started with Database Storage](https://developer.adobe.com/app-builder/docs/guides/app_builder_guides/storage/database) guide for details.)
45
-
46
- ### Basic Usage
47
-
48
- ```javascript
49
- const { generateAccessToken } = require('@adobe/aio-sdk').Core.AuthClient;
50
- const libDb = require('@adobe/aio-lib-db');
51
-
52
- // Runtime action params
53
- async function main(params) {
54
- let client;
55
- try {
56
- // Generate access token
57
- const token = await generateAccessToken(params);
58
-
59
- // Initialize library with token
60
- const db = await libDb.init({ token: token });
61
-
62
- // or with explicit region, the default being amer or whatever is defined in AIO_DB_REGION
63
- // const db = await libDb.init({ token: token, region: 'emea' });
64
-
65
- // Connect to the database
66
- client = await db.connect();
67
-
68
- // Get a collection
69
- const users = client.collection('users');
70
-
71
- // Insert a document
72
- await users.insertOne({ name: 'John Doe', email: 'john@example.com' });
73
-
74
- // Find documents
75
- const cursor = users.find({ name: 'John Doe' });
76
- const results = await cursor.toArray();
77
- }
78
- finally {
79
- if (client) {
80
- // Close any open cursors when the application is done
81
- await client.close();
82
- }
83
- }
84
- }
85
- ```
86
- ---
87
-
88
- ## Collection Operations
89
-
90
- ### Insert Operations
91
-
92
- ```javascript
93
- // Insert a single document
94
- const result = await collection.insertOne({
95
- name: 'Jane Smith',
96
- email: 'jane@example.com',
97
- age: 30
98
- });
99
-
100
- // Insert multiple documents
101
- const result = await collection.insertMany([
102
- { name: 'Alice', email: 'alice@example.com' },
103
- { name: 'Bob', email: 'bob@example.com' }
104
- ]);
105
- ```
106
-
107
- ### Find Operations
108
-
109
- ```javascript
110
- // Find one document
111
- const user = await collection.findOne({ email: 'john@example.com' });
112
-
113
- // Find all documents matching a filter
114
- const cursor = collection.find({ age: { $gte: 18 } });
115
- const adults = await cursor.toArray();
116
-
117
- // Find with projection and sorting
118
- const cursor = collection.find({ age: { $gte: 18 } })
119
- .project({ name: 1, email: 1 })
120
- .sort({ name: 1 })
121
- .limit(10);
122
- ```
123
-
124
- ### Update Operations
125
-
126
- ```javascript
127
- // Update one document
128
- const result = await collection.updateOne(
129
- { email: 'john@example.com' },
130
- { $set: { age: 31 } }
131
- );
132
-
133
- // Update multiple documents
134
- const result = await collection.updateMany(
135
- { age: { $lt: 18 } },
136
- { $set: { category: 'minor' } }
137
- );
138
-
139
- // Find and update
140
- const updatedUser = await collection.findOneAndUpdate(
141
- { email: 'john@example.com' },
142
- { $set: { lastLogin: new Date() } },
143
- { returnDocument: 'after' }
144
- );
145
- ```
146
-
147
- ### Delete Operations
148
-
149
- ```javascript
150
- // Delete one document
151
- const result = await collection.deleteOne({ email: 'john@example.com' });
152
-
153
- // Delete multiple documents
154
- const result = await collection.deleteMany({ age: { $lt: 0 } });
155
-
156
- // Find and delete
157
- const deletedUser = await collection.findOneAndDelete({ email: 'john@example.com' });
158
- ```
159
-
160
- ---
161
-
162
- ## Query Building with Cursors
163
-
164
- > Cursors will close themselves after all results have been processed, but they can be closed early to release resources by calling `cursor.close()`, and best practice is to close them explicitly once they're no longer needed. The `client.close()` method will close all open cursors and connections, so it should be called when the application is shutting down or no longer needs database access.
165
-
166
- ### FindCursor Methods
167
-
168
- The `find()` method returns a `FindCursor` that supports method chaining:
169
-
170
- ```javascript
171
- const cursor = collection.find({ status: 'active' })
172
- .filter({ category: 'premium' }) // Additional filtering
173
- .sort({ createdAt: -1 }) // Sort by creation date (newest first)
174
- .project({ name: 1, email: 1, _id: 0 }) // Only include name and email
175
- .limit(20) // Limit to 20 results
176
- .skip(10) // Skip first 10 results
177
- .batchSize(5); // Process in batches of 5
178
- ```
179
-
180
- ### Cursor Iteration
181
-
182
- ```javascript
183
- // Using toArray() - loads all results into memory
184
- const results = await cursor.toArray();
185
-
186
- // Using iteration - memory efficient
187
- while (await cursor.hasNext()) {
188
- const doc = await cursor.next();
189
- console.log(doc);
190
- }
191
-
192
- // Using for await...of - most convenient
193
- for await (const doc of cursor) {
194
- console.log(doc);
195
- }
196
-
197
- // Using streams
198
- const stream = cursor.stream();
199
- stream.on('data', (doc) => {
200
- console.log(doc);
201
- });
202
-
203
- // Check cursor properties
204
- console.log('Cursor ID:', cursor.id);
205
- console.log('Is closed:', cursor.closed);
206
- ```
207
-
208
- ### Cursor Transformations
209
-
210
- ```javascript
211
- // Transform documents as they're retrieved
212
- const cursor = collection.find({ status: 'active' })
213
- .map(doc => ({
214
- ...doc,
215
- displayName: `${doc.firstName} ${doc.lastName}`,
216
- isVip: doc.tier === 'premium'
217
- }));
218
-
219
- // Chain multiple transformations
220
- const cursor = collection.find({ status: 'active' })
221
- .map(doc => ({ ...doc, processed: true }))
222
- .map(doc => ({ ...doc, timestamp: new Date() }));
223
- ```
224
-
225
- ---
226
-
227
- ## Aggregation Pipeline
228
-
229
- ### Basic Aggregation
230
-
231
- ```javascript
232
- // Simple aggregation
233
- const pipeline = [
234
- { $match: { status: 'active' } },
235
- { $group: { _id: '$category', count: { $sum: 1 } } },
236
- { $sort: { count: -1 } }
237
- ];
238
-
239
- const cursor = collection.aggregate(pipeline);
240
- const results = await cursor.toArray();
241
- ```
242
-
243
- ### Chained Aggregation Building
244
-
245
- ```javascript
246
- // Build aggregation pipeline using method chaining
247
- const cursor = collection.aggregate()
248
- .match({ status: 'active' })
249
- .group({ _id: '$category', total: { $sum: '$amount' } })
250
- .sort({ total: -1 })
251
- .limit(10)
252
- .project({ category: '$_id', total: 1, _id: 0 });
253
-
254
- const topCategories = await cursor.toArray();
255
-
256
- // Geospatial aggregation example
257
- const nearbyStores = await collection.aggregate()
258
- .geoNear({
259
- near: { type: 'Point', coordinates: [-122.4194, 37.7749] }, // San Francisco
260
- distanceField: 'distance',
261
- maxDistance: 1000, // 1km radius
262
- spherical: true
263
- })
264
- .match({ status: 'open' })
265
- .limit(10)
266
- .toArray();
267
- ```
268
-
269
- ### Advanced Aggregation
270
-
271
- ```javascript
272
- // Complex aggregation with multiple stages
273
- const cursor = collection.aggregate()
274
- .match({ dateCreated: { $gte: new Date('2024-01-01') } })
275
- .lookup({
276
- from: 'categories',
277
- localField: 'categoryId',
278
- foreignField: '_id',
279
- as: 'category'
280
- })
281
- .unwind('$category')
282
- .redact({
283
- $cond: {
284
- if: { $eq: ['$category.status', 'active'] },
285
- then: '$$DESCEND',
286
- else: '$$PRUNE'
287
- }
288
- })
289
- .group({
290
- _id: '$category.name',
291
- totalSales: { $sum: '$amount' },
292
- averageOrder: { $avg: '$amount' },
293
- orderCount: { $sum: 1 }
294
- })
295
- .sort({ totalSales: -1 })
296
- .limit(5)
297
- .out('sales_summary'); // Output results to a new collection
298
- ```
299
-
300
- ---
301
-
302
- ## Advanced Features
303
-
304
- ### Storage Statics
305
-
306
- #### Individual database statistics:
307
-
308
- ```javascript
309
- // Get storage statistics for the database with the default scale factor (bytes)
310
- const dbStats = client.dbStats()
311
-
312
- // Get storage statistics for the database with a scale factor (e.g. KB)
313
- const dbStatsKb = client.dbStats({ scale: 1024 })
314
- ```
315
- | field returned | description |
316
- |----------------|-------------------------------------------------------------------------------------------------|
317
- | collections | the number of collections |
318
- | objects | the number of objects/documents |
319
- | views | the number of views (not currently supported) |
320
- | indexes | the number of indexes |
321
- | dataSize | the actual amount of storage used (default bytes) |
322
- | storageSize | space allocated for storage (default bytes) |
323
- | indexSize | space allocated for indexes (default bytes) |
324
- | ok | whether the request was successful |
325
- | scaleFactor | the scale factor used for the size fields, ex: 1024 for kilobyte-scale (default is 1 for bytes) |
326
- | lastUpdated | when the statistics were last updated |
327
-
328
- #### Organization storage statistics:
329
-
330
- ```javascript
331
- // Get combined storage statistics across databases in the organization with the default scale factor (bytes)
332
- const orgStats = client.orgStats()
333
-
334
- // Get combined storage statistics across databases in the organization with a scale factor (e.g. MB)
335
- const orgStatsMb = client.orgStats({ scale: 1024 * 1024 })
336
- ```
337
- | field returned | description |
338
- |---------------------------|----------------------------------------------------------------------------------------------------------------------------|
339
- | ok | whether the request was successful |
340
- | databases | the number of databases in the organization |
341
- | collections | the total number of collections across databases |
342
- | dataSize | the total actual amount of storage used across databases (default bytes) |
343
- | storageSize | space allocated for storage (default bytes) |
344
- | indexSize | space allocated for indexes (default bytes) |
345
- | scaleFactor | the scale factor used for the size fields, ex: 1024 for kilobyte-scale (default is 1 for bytes) |
346
- | databaseStats | an array of statistics for individual databases in the organization |
347
- | databaseStats.namespace | the runtime namespace the database corresponds to |
348
- | databaseStats.dataSize | the actual amount of storage used by the database (default bytes) |
349
- | databaseStats.storageSize | space allocated for storage for the database (default bytes) |
350
- | databaseStats.indexSize | space allocated for indexes for the database (default bytes) |
351
- | databaseStats.collections | the number of collections in the database |
352
- | databaseStats.scaleFactor | the scale factor used for the size fields in the databaseStats array, ex: 1024 for kilobyte-scale (default is 1 for bytes) |
353
- | databaseStats.lastUpdated | when the database statistics were last updated |
354
-
355
- ### Indexing
356
-
357
- ```javascript
358
- // Create indexes for better query performance
359
- await collection.createIndex({ email: 1 }, { unique: true });
360
- await collection.createIndex({ 'profile.age': 1, status: 1 });
361
-
362
- // List all indexes
363
- const indexes = await collection.getIndexes();
364
-
365
- // Drop an index
366
- await collection.dropIndex('email_1');
367
- ```
368
-
369
- ### Counting Documents
370
-
371
- ```javascript
372
- // Fast count estimate (uses collection metadata)
373
- const estimate = await collection.estimatedDocumentCount();
374
-
375
- // Accurate count with filter (scans documents)
376
- const activeUsers = await collection.countDocuments({ status: 'active' });
377
-
378
- // Count all documents accurately
379
- const totalExact = await collection.countDocuments({});
380
- ```
381
-
382
- ### Bulk Operations
383
-
384
- ```javascript
385
- // Perform multiple operations in a single request
386
- const operations = [
387
- { insertOne: { document: { name: 'Alice' } } },
388
- { updateOne: { filter: { name: 'Bob' }, update: { $set: { age: 30 } } } },
389
- { deleteOne: { filter: { name: 'Charlie' } } }
390
- ];
391
-
392
- const result = await collection.bulkWrite(operations);
393
- ```
394
-
395
- ### Collection Management
396
-
397
- ```javascript
398
- // Drop a collection (permanently delete)
399
- await collection.drop();
400
-
401
- // Rename a collection
402
- await collection.renameCollection('new_collection_name');
403
-
404
- // Create a new collection with options
405
- const newCollection = await client.createCollection('analytics', {
406
- validator: {
407
- $jsonSchema: {
408
- required: ['userId', 'action', 'timestamp'],
409
- properties: {
410
- userId: { type: 'string' },
411
- action: { type: 'string' },
412
- timestamp: { type: 'date' }
413
- }
414
- }
415
- }
416
- });
417
- ```
418
-
419
- ### Query Options
420
-
421
- ```javascript
422
- // Advanced query options
423
- const cursor = collection.find({ status: 'active' })
424
- .hint({ status: 1 }) // Use specific index
425
- .maxTimeMS(5000) // Set query timeout
426
- .readConcern({ level: 'majority' }) // Set read concern
427
- .collation({ locale: 'en', strength: 2 }) // Case-insensitive sorting
428
- .noCursorTimeout(true); // Disable cursor timeout
429
- ```
430
-
431
- ---
432
-
433
- ## Error Handling
434
-
435
- ```javascript
436
- try {
437
- await collection.insertOne({ email: 'invalid-email' });
438
- } catch (error) {
439
- if (error.name == 'DbError') {
440
- console.error('Database error:', error.message);
441
- } else {
442
- console.error('Unexpected error:', error);
443
- }
444
- }
445
- ```
446
-
447
- ---
448
-
449
- ## Best Practices
450
-
451
- ### 1. **Always Close Connections**
452
-
453
- ```javascript
454
- const client = await db.connect();
455
- try {
456
- // Your database operations
457
- } finally {
458
- await client.close();
459
- }
460
- ```
461
-
462
- ### 2. **Use Projections for Large Documents**
463
-
464
- ```javascript
465
- // Only fetch needed fields
466
- const users = await collection.find({})
467
- .project({ name: 1, email: 1, _id: 0 })
468
- .toArray();
469
- ```
470
-
471
- ### 3. **Use Indexes for Frequent Queries**
472
-
473
- ```javascript
474
- // Create indexes for frequently queried fields
475
- await collection.createIndex({ email: 1 });
476
- await collection.createIndex({ status: 1, createdAt: -1 });
477
- ```
478
-
479
- ### 4. **Handle Large Result Sets with Cursors**
480
-
481
- ```javascript
482
- // For large datasets, use cursor iteration instead of toArray()
483
- for await (const doc of collection.find({})) {
484
- // Process one document at a time
485
- await processDocument(doc);
486
- }
487
- ```
488
-
489
- ### 5. **Use Aggregation for Complex Queries**
490
-
491
- ```javascript
492
- // Use aggregation for complex data processing
493
- const report = await collection.aggregate()
494
- .match({ date: { $gte: startDate } })
495
- .group({ _id: '$category', total: { $sum: '$amount' } })
496
- .sort({ total: -1 })
497
- .toArray();
498
- ```
499
-
500
- ---
501
-
502
- ## API Reference
503
-
504
- ### DbClient
505
-
506
- - `dbStats()` - Get database statistics
507
- - `listCollections(filter?, options?)` - List collections
508
- - `collection(name)` - Get collection instance
509
- - `createCollection(name, options?)` - Create new collection
510
- - `close()` - Close the connection and all open cursors
511
-
512
- ### DbCollection
513
-
514
- **Insert Operations:**
515
- - `insertOne(document, options?)` - Insert single document
516
- - `insertMany(documents, options?)` - Insert multiple documents
517
-
518
- **Find Operations:**
519
- - `findOne(filter, options?)` - Find single document
520
- - `find(filter?, options?)` - Find multiple documents (returns FindCursor)
521
- - `findArray(filter?, options?)` - Find single batch as array
522
-
523
- **Update Operations:**
524
- - `updateOne(filter, update, options?)` - Update single document
525
- - `updateMany(filter, update, options?)` - Update multiple documents
526
- - `findOneAndUpdate(filter, update, options?)` - Find and update
527
- - `replaceOne(filter, replacement, options?)` - Replace document
528
-
529
- **Delete Operations:**
530
- - `deleteOne(filter, options?)` - Delete single document
531
- - `deleteMany(filter, options?)` - Delete multiple documents
532
- - `findOneAndDelete(filter, options?)` - Find and delete
533
-
534
- **Aggregation:**
535
- - `aggregate(pipeline?, options?)` - Run aggregation (returns AggregateCursor)
536
-
537
- **Utility Operations:**
538
- - `countDocuments(filter?, options?)` - Count documents
539
- - `estimatedDocumentCount(options?)` - Estimate document count from metadata
540
- - `distinct(field, filter?, options?)` - Get distinct values
541
- - `bulkWrite(operations, options?)` - Bulk operations
542
-
543
- **Statistics & Monitoring:**
544
- - `stats(options?)` - Get collection statistics
545
-
546
- **Index Operations:**
547
- - `createIndex(specification, options?)` - Create index
548
- - `getIndexes()` - List indexes
549
- - `dropIndex(indexName, options?)` - Drop index
550
-
551
- **Collection Management:**
552
- - `drop(options?)` - Drop the collection
553
- - `renameCollection(newName, options?)` - Rename collection
554
-
555
- ### FindCursor
556
-
557
- **Query Building:**
558
- - `filter(filter)` - Set query filter
559
- - `sort(sort, direction?)` - Set sort order
560
- - `project(projection)` - Set field projection
561
- - `limit(limit)` - Set result limit
562
- - `skip(skip)` - Set number to skip
563
- - `batchSize(size)` - Set batch size
564
-
565
- **Iteration:**
566
- - `hasNext()` - Check if more results available
567
- - `next()` - Get next document
568
- - `toArray()` - Get all results as array
569
-
570
- **Properties:**
571
- - `id` - Get cursor ID
572
- - `closed` - Check if cursor is closed and exhausted
573
-
574
- **Utilities:**
575
- - `map(transform)` - Transform documents
576
- - `stream(transform?)` - Get readable stream
577
- - `close()` - Close the cursor and release resources
578
-
579
- ### AggregateCursor
580
-
581
- **Pipeline Building:**
582
- - `match(filter)` - Add $match stage
583
- - `group(groupSpec)` - Add $group stage
584
- - `sort(sort)` - Add $sort stage
585
- - `project(projection)` - Add $project stage
586
- - `limit(limit)` - Add $limit stage
587
- - `skip(skip)` - Add $skip stage
588
- - `lookup(lookupSpec)` - Add $lookup stage
589
- - `unwind(path)` - Add $unwind stage
590
- - `out(outSpec)` - Add $out stage (output to collection)
591
- - `redact(redactSpec)` - Add $redact stage (conditional filtering)
592
- - `geoNear(geoNearSpec)` - Add $geoNear stage (geospatial queries)
593
- - `addStage(stage)` - Add custom stage
594
-
595
- **Iteration:** (Same as FindCursor)
596
- - `hasNext()`, `next()`, `toArray()`, `stream()`, etc.
15
+ ## Documentation
597
16
 
598
- **Properties:**
599
- - `id` - Get cursor ID
600
- - `closed` - Check if cursor is closed and exhausted
17
+ Main documentation for App Builder Database Storage:
601
18
 
602
- **Utilities:**
603
- - `close()` - Close the cursor and release resources
19
+ * [Getting Started with Database Storage](https://developer.adobe.com/app-builder/docs/guides/app_builder_guides/storage/database)
604
20
 
605
21
  ---
606
22
 
package/lib/DbBase.js CHANGED
@@ -19,6 +19,7 @@ const {
19
19
  ALLOWED_REGIONS, STAGE_ENV, STAGE_ENDPOINT, PROD_ENDPOINT_RUNTIME, PROD_ENDPOINT_EXTERNAL
20
20
  } = require("./constants")
21
21
  const { getCliEnv } = require("@adobe/aio-lib-env")
22
+ const { isProdWorkspace } = require("../utils/runtimeNamespace")
22
23
 
23
24
  class DbBase {
24
25
  /**
@@ -28,7 +29,6 @@ class DbBase {
28
29
  * @hideconstructor
29
30
  */
30
31
  constructor(region, runtimeNamespace, token) {
31
-
32
32
  this.runtimeNamespace = runtimeNamespace
33
33
  if (!this.runtimeNamespace) {
34
34
  throw new DbError('Runtime namespace is required')
@@ -46,6 +46,10 @@ class DbBase {
46
46
  throw new DbError(`Invalid region '${region}' for the ${env} environment, must be one of: ${validRegions.join(', ')}`)
47
47
  }
48
48
 
49
+ if (process.env.AIO_DEV && isProdWorkspace(this.runtimeNamespace)) {
50
+ throw new DbError('Cannot access production databases when using \'aio app dev\'.')
51
+ }
52
+
49
53
  let serviceUrl
50
54
  // Allow overriding service URL via environment variable for testing
51
55
  if (process.env.AIO_DB_ENDPOINT) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/aio-lib-db",
3
- "version": "0.2.0-beta.1",
3
+ "version": "1.0.1",
4
4
  "description": "An abstraction on top of Document DB storage",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -0,0 +1,27 @@
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
+
13
+ const prodWorkspaceCheck = new RegExp(`^(development-)?\\d+-[a-z0-9]+$`, 'i')
14
+
15
+ /**
16
+ * Checks if the namespace is for a production workspace
17
+ * Production: 123456-testProject / development-123456-testProject
18
+ * Non-production: 123456-testProject-<tag> / development-123456-testProject-<tag>
19
+ *
20
+ * @param {string} runtimeNamespace
21
+ * @return {boolean}
22
+ **/
23
+ function isProdWorkspace(runtimeNamespace) {
24
+ return prodWorkspaceCheck.test(runtimeNamespace)
25
+ }
26
+
27
+ module.exports = { isProdWorkspace }