@unchainedshop/mongodb 4.4.0 → 4.6.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.
- package/README.md +101 -0
- package/lib/initDb.d.ts +17 -2
- package/lib/initDb.js +59 -17
- package/lib/insensitive-trimmed-regex-operator.d.ts +1 -0
- package/lib/insensitive-trimmed-regex-operator.js +10 -4
- package/lib/mongodb-index.d.ts +3 -2
- package/lib/mongodb-index.js +2 -2
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -84,6 +84,107 @@ await stopDb(db);
|
|
|
84
84
|
|----------|-------------|
|
|
85
85
|
| `UNCHAINED_DOCUMENTDB_COMPAT_MODE` | Enable AWS DocumentDB compatibility mode |
|
|
86
86
|
|
|
87
|
+
## Best Practices
|
|
88
|
+
|
|
89
|
+
### Collection Naming Conventions
|
|
90
|
+
|
|
91
|
+
Unchained uses the following collection naming patterns:
|
|
92
|
+
|
|
93
|
+
| Pattern | Example | Usage |
|
|
94
|
+
|---------|---------|-------|
|
|
95
|
+
| Plural lowercase | `products`, `orders`, `users` | Main entity collections |
|
|
96
|
+
| Underscore-separated | `product_texts`, `product_media` | Related sub-collections |
|
|
97
|
+
|
|
98
|
+
**Note:** Some legacy collections may use different patterns. When creating new collections, prefer the underscore-separated pattern for sub-collections.
|
|
99
|
+
|
|
100
|
+
### Index Guidelines
|
|
101
|
+
|
|
102
|
+
#### Using `buildDbIndexes`
|
|
103
|
+
|
|
104
|
+
Always use the `buildDbIndexes` helper to create indexes:
|
|
105
|
+
|
|
106
|
+
```typescript
|
|
107
|
+
import { buildDbIndexes } from '@unchainedshop/mongodb';
|
|
108
|
+
|
|
109
|
+
await buildDbIndexes<Product>(Products, [
|
|
110
|
+
{ index: { deleted: 1 } }, // Soft delete support
|
|
111
|
+
{ index: { status: 1 } }, // Query by status
|
|
112
|
+
{ index: { slugs: 1 } }, // URL slug lookups
|
|
113
|
+
{ index: { tags: 1 } }, // Tag filtering
|
|
114
|
+
]);
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
#### Soft Delete Pattern
|
|
118
|
+
|
|
119
|
+
Collections using soft delete should always include a `deleted` index:
|
|
120
|
+
|
|
121
|
+
```typescript
|
|
122
|
+
{ index: { deleted: 1 } }
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Queries should filter by `deleted: null` to exclude soft-deleted documents.
|
|
126
|
+
|
|
127
|
+
#### Sparse Indexes
|
|
128
|
+
|
|
129
|
+
Use sparse indexes when the indexed field may be null/undefined for most documents:
|
|
130
|
+
|
|
131
|
+
```typescript
|
|
132
|
+
{
|
|
133
|
+
index: { optionalField: 1 },
|
|
134
|
+
options: { sparse: true }
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Sparse indexes are smaller and more efficient when the field is rarely present.
|
|
139
|
+
|
|
140
|
+
#### Text Indexes
|
|
141
|
+
|
|
142
|
+
For full-text search, create compound text indexes:
|
|
143
|
+
|
|
144
|
+
```typescript
|
|
145
|
+
{
|
|
146
|
+
index: {
|
|
147
|
+
_id: 'text',
|
|
148
|
+
name: 'text',
|
|
149
|
+
description: 'text',
|
|
150
|
+
} as any,
|
|
151
|
+
options: {
|
|
152
|
+
weights: {
|
|
153
|
+
_id: 10,
|
|
154
|
+
name: 5,
|
|
155
|
+
description: 1,
|
|
156
|
+
},
|
|
157
|
+
name: 'fulltext_search',
|
|
158
|
+
},
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
**Important:** Text indexes are not supported in DocumentDB compatibility mode. Use `isDocumentDBCompatModeEnabled()` to conditionally create them.
|
|
163
|
+
|
|
164
|
+
### DocumentDB Compatibility
|
|
165
|
+
|
|
166
|
+
When running on AWS DocumentDB, some MongoDB features are not supported:
|
|
167
|
+
|
|
168
|
+
| Feature | Alternative |
|
|
169
|
+
|---------|-------------|
|
|
170
|
+
| Text indexes | Use application-level search or external search service |
|
|
171
|
+
| `$text` queries | Use regex queries (less performant) |
|
|
172
|
+
| Some aggregation operators | Check DocumentDB documentation |
|
|
173
|
+
|
|
174
|
+
Use the compatibility helpers:
|
|
175
|
+
|
|
176
|
+
```typescript
|
|
177
|
+
import { isDocumentDBCompatModeEnabled, assertDocumentDBCompatMode } from '@unchainedshop/mongodb';
|
|
178
|
+
|
|
179
|
+
// Check before using unsupported features
|
|
180
|
+
if (!isDocumentDBCompatModeEnabled()) {
|
|
181
|
+
// Create text index
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Throw error if DocumentDB mode is enabled
|
|
185
|
+
assertDocumentDBCompatMode(); // Throws if in compat mode
|
|
186
|
+
```
|
|
187
|
+
|
|
87
188
|
## License
|
|
88
189
|
|
|
89
190
|
EUPL-1.2
|
package/lib/initDb.d.ts
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
import type { Db } from 'mongodb';
|
|
2
|
-
|
|
2
|
+
import { MongoClient } from 'mongodb';
|
|
3
|
+
export declare const startDb: (options?: {
|
|
4
|
+
forceInMemory?: boolean;
|
|
5
|
+
port?: number;
|
|
6
|
+
}) => Promise<string>;
|
|
3
7
|
export declare const stopDb: () => Promise<void>;
|
|
4
|
-
|
|
8
|
+
export interface DatabaseResource extends AsyncDisposable {
|
|
9
|
+
db: Db;
|
|
10
|
+
client: MongoClient;
|
|
11
|
+
}
|
|
12
|
+
export declare const createDatabaseResource: (options?: {
|
|
13
|
+
forceInMemory?: boolean;
|
|
14
|
+
port?: number;
|
|
15
|
+
}) => Promise<DatabaseResource>;
|
|
16
|
+
declare const initDb: (options?: {
|
|
17
|
+
forceInMemory?: boolean;
|
|
18
|
+
port?: number;
|
|
19
|
+
}) => Promise<Db>;
|
|
5
20
|
export { initDb };
|
package/lib/initDb.js
CHANGED
|
@@ -1,51 +1,93 @@
|
|
|
1
1
|
import { MongoClient } from 'mongodb';
|
|
2
2
|
let zstdEnabled = false;
|
|
3
|
-
let mongod;
|
|
4
|
-
let mongoClient;
|
|
5
|
-
|
|
3
|
+
let mongod = null;
|
|
4
|
+
let mongoClient = null;
|
|
5
|
+
let useEphemeralStorage = false;
|
|
6
|
+
const CLEANUP_TIMEOUT_MS = 10000;
|
|
6
7
|
try {
|
|
7
8
|
await import('@mongodb-js/zstd');
|
|
8
9
|
zstdEnabled = true;
|
|
9
10
|
}
|
|
10
11
|
catch {
|
|
11
12
|
}
|
|
12
|
-
export const startDb = async () => {
|
|
13
|
+
export const startDb = async (options) => {
|
|
13
14
|
const { mkdir } = await import('node:fs/promises');
|
|
14
15
|
const { MongoMemoryServer } = await import('mongodb-memory-server');
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
const useInMemory = options?.forceInMemory || process.env.NODE_ENV === 'test';
|
|
17
|
+
useEphemeralStorage = useInMemory;
|
|
18
|
+
const mongoPort = options?.port ?? parseInt(process.env.PORT || '4010', 10) + 1;
|
|
19
|
+
if (!useInMemory) {
|
|
20
|
+
try {
|
|
21
|
+
await mkdir(`${process.cwd()}/.db`);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
}
|
|
19
25
|
}
|
|
20
26
|
try {
|
|
21
27
|
mongod = MongoMemoryServer.create({
|
|
22
|
-
instance:
|
|
23
|
-
? { dbName: 'test', port:
|
|
28
|
+
instance: useInMemory
|
|
29
|
+
? { dbName: 'test', port: mongoPort, storageEngine: 'ephemeralForTest' }
|
|
24
30
|
: {
|
|
25
31
|
dbPath: `${process.cwd()}/.db`,
|
|
26
32
|
storageEngine: 'wiredTiger',
|
|
27
|
-
port:
|
|
33
|
+
port: mongoPort,
|
|
28
34
|
},
|
|
29
35
|
});
|
|
30
36
|
const mongoInstance = await mongod;
|
|
31
37
|
if (mongoInstance) {
|
|
32
|
-
return `${mongoInstance.getUri()}${
|
|
38
|
+
return `${mongoInstance.getUri()}${useInMemory ? 'test' : 'unchained'}`;
|
|
33
39
|
}
|
|
34
40
|
}
|
|
35
|
-
catch {
|
|
41
|
+
catch (e) {
|
|
42
|
+
const error = e;
|
|
43
|
+
if (error.message?.includes('code "62"')) {
|
|
44
|
+
throw new Error(`MongoDB database files in .db are incompatible with the current MongoDB version. ` +
|
|
45
|
+
`This usually happens after a MongoDB upgrade. ` +
|
|
46
|
+
`To fix this, remove the .db directory: rm -rf ${process.cwd()}/.db`);
|
|
47
|
+
}
|
|
48
|
+
throw error;
|
|
36
49
|
}
|
|
37
50
|
throw new Error("Can't connect to MongoDB: Could not start mongodb-memory-server and MONGO_URL env is not set");
|
|
38
51
|
};
|
|
39
52
|
export const stopDb = async () => {
|
|
40
|
-
|
|
53
|
+
const cleanup = async () => {
|
|
41
54
|
await mongoClient?.close();
|
|
42
|
-
|
|
55
|
+
const server = await mongod;
|
|
56
|
+
await server?.stop({ doCleanup: useEphemeralStorage, force: true });
|
|
57
|
+
};
|
|
58
|
+
const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error('Database cleanup timeout')), CLEANUP_TIMEOUT_MS));
|
|
59
|
+
try {
|
|
60
|
+
await Promise.race([cleanup(), timeout]);
|
|
43
61
|
}
|
|
44
62
|
catch {
|
|
63
|
+
const server = await mongod;
|
|
64
|
+
await server?.stop({ force: true }).catch(() => {
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
mongoClient = null;
|
|
69
|
+
mongod = null;
|
|
45
70
|
}
|
|
46
71
|
};
|
|
47
|
-
const
|
|
48
|
-
const url =
|
|
72
|
+
export const createDatabaseResource = async (options) => {
|
|
73
|
+
const url = options?.forceInMemory
|
|
74
|
+
? await startDb({ forceInMemory: true, port: options.port })
|
|
75
|
+
: process.env.MONGO_URL || (await startDb({ port: options?.port }));
|
|
76
|
+
mongoClient = new MongoClient(url, {
|
|
77
|
+
compressors: zstdEnabled ? 'zstd' : undefined,
|
|
78
|
+
});
|
|
79
|
+
await mongoClient.connect();
|
|
80
|
+
const db = mongoClient.db();
|
|
81
|
+
return {
|
|
82
|
+
db,
|
|
83
|
+
client: mongoClient,
|
|
84
|
+
[Symbol.asyncDispose]: stopDb,
|
|
85
|
+
};
|
|
86
|
+
};
|
|
87
|
+
const initDb = async (options) => {
|
|
88
|
+
const url = options?.forceInMemory
|
|
89
|
+
? await startDb({ forceInMemory: true, port: options.port })
|
|
90
|
+
: process.env.MONGO_URL || (await startDb({ port: options?.port }));
|
|
49
91
|
mongoClient = new MongoClient(url, {
|
|
50
92
|
compressors: zstdEnabled ? 'zstd' : undefined,
|
|
51
93
|
});
|
|
@@ -1,11 +1,17 @@
|
|
|
1
|
+
export function escapeRegexString(string) {
|
|
2
|
+
if (typeof string !== 'string') {
|
|
3
|
+
throw new TypeError('Expected a string');
|
|
4
|
+
}
|
|
5
|
+
return string.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
|
|
6
|
+
}
|
|
1
7
|
export function insensitiveTrimmedRegexOperator(string) {
|
|
2
8
|
if (typeof string !== 'string') {
|
|
3
9
|
throw new TypeError('Expected a string');
|
|
4
10
|
}
|
|
5
|
-
const
|
|
6
|
-
if (
|
|
11
|
+
const escaped = escapeRegexString(string.trim());
|
|
12
|
+
if (escaped.length === 0)
|
|
7
13
|
throw new Error('String is empty after trimming');
|
|
8
|
-
if (
|
|
14
|
+
if (escaped.length > 255)
|
|
9
15
|
throw new Error('String exceeds maximum allowed length');
|
|
10
|
-
return { $regex: new RegExp(`^${
|
|
16
|
+
return { $regex: new RegExp(`^${escaped}$`, 'i') };
|
|
11
17
|
}
|
package/lib/mongodb-index.d.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import * as mongodb from 'mongodb';
|
|
2
|
-
export { initDb, startDb, stopDb } from './initDb.ts';
|
|
2
|
+
export { initDb, startDb, stopDb, createDatabaseResource } from './initDb.ts';
|
|
3
|
+
export type { DatabaseResource } from './initDb.ts';
|
|
3
4
|
export { generateDbObjectId } from './generate-db-object-id.ts';
|
|
4
5
|
export { generateDbFilterById } from './generate-db-filter-by-id.ts';
|
|
5
6
|
export { buildDbIndexes } from './build-db-indexes.ts';
|
|
6
7
|
export { findPreservingIds } from './find-preserving-ids.ts';
|
|
7
8
|
export { buildSortOptions } from './build-sort-option.ts';
|
|
8
9
|
export { findLocalizedText } from './find-localized-text.ts';
|
|
9
|
-
export { insensitiveTrimmedRegexOperator } from './insensitive-trimmed-regex-operator.ts';
|
|
10
|
+
export { insensitiveTrimmedRegexOperator, escapeRegexString, } from './insensitive-trimmed-regex-operator.ts';
|
|
10
11
|
export * from './documentdb-compat-mode.ts';
|
|
11
12
|
export { mongodb };
|
|
12
13
|
export interface LogFields {
|
package/lib/mongodb-index.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import * as mongodb from 'mongodb';
|
|
2
|
-
export { initDb, startDb, stopDb } from "./initDb.js";
|
|
2
|
+
export { initDb, startDb, stopDb, createDatabaseResource } from "./initDb.js";
|
|
3
3
|
export { generateDbObjectId } from "./generate-db-object-id.js";
|
|
4
4
|
export { generateDbFilterById } from "./generate-db-filter-by-id.js";
|
|
5
5
|
export { buildDbIndexes } from "./build-db-indexes.js";
|
|
6
6
|
export { findPreservingIds } from "./find-preserving-ids.js";
|
|
7
7
|
export { buildSortOptions } from "./build-sort-option.js";
|
|
8
8
|
export { findLocalizedText } from "./find-localized-text.js";
|
|
9
|
-
export { insensitiveTrimmedRegexOperator } from "./insensitive-trimmed-regex-operator.js";
|
|
9
|
+
export { insensitiveTrimmedRegexOperator, escapeRegexString, } from "./insensitive-trimmed-regex-operator.js";
|
|
10
10
|
export * from "./documentdb-compat-mode.js";
|
|
11
11
|
export { mongodb };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@unchainedshop/mongodb",
|
|
3
|
-
"version": "4.
|
|
4
|
-
"description": "MongoDB
|
|
3
|
+
"version": "4.6.0",
|
|
4
|
+
"description": "MongoDB database abstraction layer for the Unchained Engine",
|
|
5
5
|
"main": "lib/mongodb-index.js",
|
|
6
6
|
"types": "lib/mongodb-index.d.ts",
|
|
7
7
|
"type": "module",
|
|
@@ -33,12 +33,12 @@
|
|
|
33
33
|
},
|
|
34
34
|
"homepage": "https://github.com/unchainedshop/unchained#readme",
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@unchainedshop/utils": "^4.
|
|
36
|
+
"@unchainedshop/utils": "^4.6.0"
|
|
37
37
|
},
|
|
38
38
|
"peerDependencies": {
|
|
39
|
-
"@mongodb-js/zstd": ">=
|
|
40
|
-
"mongodb": ">=
|
|
41
|
-
"mongodb-memory-server": ">=
|
|
39
|
+
"@mongodb-js/zstd": ">= 7 < 8",
|
|
40
|
+
"mongodb": ">= 7 < 8",
|
|
41
|
+
"mongodb-memory-server": ">= 11 < 12"
|
|
42
42
|
},
|
|
43
43
|
"peerDependenciesMeta": {
|
|
44
44
|
"mongodb": {
|