liwi-mongo 13.1.1 → 13.3.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 +245 -3
- package/dist/definitions/MongoRegistry.d.ts +20 -0
- package/dist/definitions/MongoRegistry.d.ts.map +1 -0
- package/dist/definitions/MongoRegistry.test.d.ts +2 -0
- package/dist/definitions/MongoRegistry.test.d.ts.map +1 -0
- package/dist/definitions/MongoRegistryStore.d.ts +7 -0
- package/dist/definitions/MongoRegistryStore.d.ts.map +1 -0
- package/dist/definitions/MongoStore.d.ts +9 -1
- package/dist/definitions/MongoStore.d.ts.map +1 -1
- package/dist/definitions/MongoStoreIndexes.test.d.ts +2 -0
- package/dist/definitions/MongoStoreIndexes.test.d.ts.map +1 -0
- package/dist/definitions/index.d.ts +8 -1
- package/dist/definitions/index.d.ts.map +1 -1
- package/dist/definitions/indexes/applyIndexPlan.d.ts +11 -0
- package/dist/definitions/indexes/applyIndexPlan.d.ts.map +1 -0
- package/dist/definitions/indexes/buildIndexName.d.ts +3 -0
- package/dist/definitions/indexes/buildIndexName.d.ts.map +1 -0
- package/dist/definitions/indexes/buildIndexName.test.d.ts +2 -0
- package/dist/definitions/indexes/buildIndexName.test.d.ts.map +1 -0
- package/dist/definitions/indexes/deepEqualUnordered.d.ts +2 -0
- package/dist/definitions/indexes/deepEqualUnordered.d.ts.map +1 -0
- package/dist/definitions/indexes/diffIndexes.d.ts +13 -0
- package/dist/definitions/indexes/diffIndexes.d.ts.map +1 -0
- package/dist/definitions/indexes/diffIndexes.test.d.ts +2 -0
- package/dist/definitions/indexes/diffIndexes.test.d.ts.map +1 -0
- package/dist/definitions/indexes/formatIndexPlan.d.ts +5 -0
- package/dist/definitions/indexes/formatIndexPlan.d.ts.map +1 -0
- package/dist/definitions/indexes/formatIndexPlan.test.d.ts +2 -0
- package/dist/definitions/indexes/formatIndexPlan.test.d.ts.map +1 -0
- package/dist/definitions/indexes/isIndexConflictError.d.ts +4 -0
- package/dist/definitions/indexes/isIndexConflictError.d.ts.map +1 -0
- package/dist/definitions/indexes/normalizeIndex.d.ts +34 -0
- package/dist/definitions/indexes/normalizeIndex.d.ts.map +1 -0
- package/dist/definitions/indexes/normalizeIndex.test.d.ts +2 -0
- package/dist/definitions/indexes/normalizeIndex.test.d.ts.map +1 -0
- package/dist/definitions/indexes/runIndexesCli.d.ts +11 -0
- package/dist/definitions/indexes/runIndexesCli.d.ts.map +1 -0
- package/dist/definitions/indexes/runIndexesCli.test.d.ts +2 -0
- package/dist/definitions/indexes/runIndexesCli.test.d.ts.map +1 -0
- package/dist/definitions/indexes/syncIndexes.integration.test.d.ts +2 -0
- package/dist/definitions/indexes/syncIndexes.integration.test.d.ts.map +1 -0
- package/dist/definitions/indexes/types.d.ts +72 -0
- package/dist/definitions/indexes/types.d.ts.map +1 -0
- package/dist/index-node.mjs +626 -10
- package/dist/index-node.mjs.map +1 -1
- package/package.json +7 -5
- package/src/MongoRegistry.test.ts +136 -0
- package/src/MongoRegistry.ts +108 -0
- package/src/MongoRegistryStore.ts +11 -0
- package/src/MongoStore.ts +63 -1
- package/src/MongoStoreIndexes.test.ts +259 -0
- package/src/index.ts +26 -1
- package/src/indexes/applyIndexPlan.ts +149 -0
- package/src/indexes/buildIndexName.test.ts +34 -0
- package/src/indexes/buildIndexName.ts +8 -0
- package/src/indexes/deepEqualUnordered.ts +36 -0
- package/src/indexes/diffIndexes.test.ts +290 -0
- package/src/indexes/diffIndexes.ts +234 -0
- package/src/indexes/formatIndexPlan.test.ts +105 -0
- package/src/indexes/formatIndexPlan.ts +71 -0
- package/src/indexes/isIndexConflictError.ts +20 -0
- package/src/indexes/normalizeIndex.test.ts +154 -0
- package/src/indexes/normalizeIndex.ts +191 -0
- package/src/indexes/runIndexesCli.test.ts +119 -0
- package/src/indexes/runIndexesCli.ts +74 -0
- package/src/indexes/syncIndexes.integration.test.ts +182 -0
- package/src/indexes/types.ts +115 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const indexOptionsConflict = 85;
|
|
2
|
+
const indexKeySpecsConflict = 86;
|
|
3
|
+
const indexNotFound = 27;
|
|
4
|
+
const namespaceNotFound = 26;
|
|
5
|
+
|
|
6
|
+
const getErrorCode = (error: unknown): unknown =>
|
|
7
|
+
typeof error === "object" && error !== null
|
|
8
|
+
? (error as { code?: unknown }).code
|
|
9
|
+
: undefined;
|
|
10
|
+
|
|
11
|
+
export const isIndexConflictError = (error: unknown): boolean => {
|
|
12
|
+
const code = getErrorCode(error);
|
|
13
|
+
return code === indexOptionsConflict || code === indexKeySpecsConflict;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const isIndexNotFoundError = (error: unknown): boolean =>
|
|
17
|
+
getErrorCode(error) === indexNotFound;
|
|
18
|
+
|
|
19
|
+
export const isNamespaceNotFoundError = (error: unknown): boolean =>
|
|
20
|
+
getErrorCode(error) === namespaceNotFound;
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type { MongoBaseModel } from "../MongoBaseModel.ts";
|
|
4
|
+
import {
|
|
5
|
+
normalizeDeclaredIndex,
|
|
6
|
+
normalizeDeclaredIndexes,
|
|
7
|
+
normalizeExistingIndex,
|
|
8
|
+
} from "./normalizeIndex.ts";
|
|
9
|
+
|
|
10
|
+
interface TestModel extends MongoBaseModel {
|
|
11
|
+
completed: boolean;
|
|
12
|
+
label: string;
|
|
13
|
+
title: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("normalizeDeclaredIndex", () => {
|
|
17
|
+
it("fills boolean defaults and derives the name", () => {
|
|
18
|
+
const normalized = normalizeDeclaredIndex<TestModel>({
|
|
19
|
+
key: { completed: 1 },
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
assert.equal(normalized.name, "completed_1");
|
|
23
|
+
assert.equal(normalized.isText, false);
|
|
24
|
+
assert.deepEqual(normalized.keyEntries, [["completed", 1]]);
|
|
25
|
+
assert.equal(normalized.options.unique, false);
|
|
26
|
+
assert.equal(normalized.options.sparse, false);
|
|
27
|
+
assert.equal(normalized.options.hidden, false);
|
|
28
|
+
assert.equal(normalized.options.weights, undefined);
|
|
29
|
+
assert.equal(normalized.options.defaultLanguage, undefined);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("keeps an explicit name and builds the driver description", () => {
|
|
33
|
+
const normalized = normalizeDeclaredIndex<TestModel>({
|
|
34
|
+
key: { completed: 1 },
|
|
35
|
+
name: "custom",
|
|
36
|
+
unique: true,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
assert.equal(normalized.name, "custom");
|
|
40
|
+
assert.deepEqual(normalized.description, {
|
|
41
|
+
key: { completed: 1 },
|
|
42
|
+
name: "custom",
|
|
43
|
+
unique: true,
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("synthesizes weights and language defaults for a text index", () => {
|
|
48
|
+
const normalized = normalizeDeclaredIndex<TestModel>({
|
|
49
|
+
key: { completed: 1, label: "text" },
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
assert.equal(normalized.isText, true);
|
|
53
|
+
assert.deepEqual(normalized.keyEntries, [["completed", 1]]);
|
|
54
|
+
assert.deepEqual(normalized.options.weights, { label: 1 });
|
|
55
|
+
assert.equal(normalized.options.defaultLanguage, "english");
|
|
56
|
+
assert.equal(normalized.options.languageOverride, "language");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("throws on an empty key", () => {
|
|
60
|
+
assert.throws(
|
|
61
|
+
() => normalizeDeclaredIndex<TestModel>({ key: {} }),
|
|
62
|
+
/at least one field/,
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("throws when the index resolves to the _id index", () => {
|
|
67
|
+
assert.throws(
|
|
68
|
+
() => normalizeDeclaredIndex<TestModel>({ key: { _id: 1 } }),
|
|
69
|
+
/_id is always indexed/,
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("normalizeExistingIndex", () => {
|
|
75
|
+
it("ignores server managed fields", () => {
|
|
76
|
+
const normalized = normalizeExistingIndex({
|
|
77
|
+
v: 2,
|
|
78
|
+
key: { completed: 1 },
|
|
79
|
+
name: "completed_1",
|
|
80
|
+
ns: "test.tasks",
|
|
81
|
+
background: true,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
assert.deepEqual(normalized.options, {
|
|
85
|
+
unique: false,
|
|
86
|
+
sparse: false,
|
|
87
|
+
hidden: false,
|
|
88
|
+
expireAfterSeconds: undefined,
|
|
89
|
+
partialFilterExpression: undefined,
|
|
90
|
+
collation: undefined,
|
|
91
|
+
weights: undefined,
|
|
92
|
+
defaultLanguage: undefined,
|
|
93
|
+
languageOverride: undefined,
|
|
94
|
+
wildcardProjection: undefined,
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("reconstructs a text index from _fts / _ftsx and weights", () => {
|
|
99
|
+
const normalized = normalizeExistingIndex({
|
|
100
|
+
v: 2,
|
|
101
|
+
key: { completed: 1, _fts: "text", _ftsx: 1 },
|
|
102
|
+
name: "completed_1_label_text",
|
|
103
|
+
weights: { label: 1 },
|
|
104
|
+
// eslint-disable-next-line camelcase -- mongo option name
|
|
105
|
+
default_language: "english",
|
|
106
|
+
// eslint-disable-next-line camelcase -- mongo option name
|
|
107
|
+
language_override: "language",
|
|
108
|
+
textIndexVersion: 3,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
assert.equal(normalized.isText, true);
|
|
112
|
+
assert.deepEqual(normalized.keyEntries, [["completed", 1]]);
|
|
113
|
+
assert.deepEqual(normalized.options.weights, { label: 1 });
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("normalizeDeclaredIndexes", () => {
|
|
118
|
+
it("throws on duplicate names", () => {
|
|
119
|
+
assert.throws(
|
|
120
|
+
() =>
|
|
121
|
+
normalizeDeclaredIndexes<TestModel>({
|
|
122
|
+
collectionName: "tasks",
|
|
123
|
+
indexes: [
|
|
124
|
+
{ key: { completed: 1 }, name: "same" },
|
|
125
|
+
{ key: { label: 1 }, name: "same" },
|
|
126
|
+
],
|
|
127
|
+
}),
|
|
128
|
+
/Duplicate index name "same"/,
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("throws when more than one text index is declared", () => {
|
|
133
|
+
assert.throws(
|
|
134
|
+
() =>
|
|
135
|
+
normalizeDeclaredIndexes<TestModel>({
|
|
136
|
+
collectionName: "tasks",
|
|
137
|
+
indexes: [{ key: { label: "text" } }, { key: { title: "text" } }],
|
|
138
|
+
}),
|
|
139
|
+
/Only one text index is allowed/,
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("accepts a valid declaration", () => {
|
|
144
|
+
const normalized = normalizeDeclaredIndexes<TestModel>({
|
|
145
|
+
collectionName: "tasks",
|
|
146
|
+
indexes: [{ key: { completed: 1, created: 1 } }, { key: { label: 1 } }],
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
assert.deepEqual(
|
|
150
|
+
normalized.map(({ name }) => name),
|
|
151
|
+
["completed_1_created_1", "label_1"],
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CollationOptions,
|
|
3
|
+
Document,
|
|
4
|
+
IndexDescription,
|
|
5
|
+
IndexDescriptionInfo,
|
|
6
|
+
} from "mongodb";
|
|
7
|
+
import type { MongoBaseModel } from "../MongoBaseModel.ts";
|
|
8
|
+
import { buildIndexName } from "./buildIndexName.ts";
|
|
9
|
+
import type { MongoIndex, MongoIndexDirection } from "./types.ts";
|
|
10
|
+
|
|
11
|
+
const textDefaultLanguage = "english";
|
|
12
|
+
const textLanguageOverride = "language";
|
|
13
|
+
|
|
14
|
+
export interface NormalizedIndexOptions {
|
|
15
|
+
unique: boolean;
|
|
16
|
+
sparse: boolean;
|
|
17
|
+
hidden: boolean;
|
|
18
|
+
expireAfterSeconds?: number;
|
|
19
|
+
partialFilterExpression?: Document;
|
|
20
|
+
collation?: CollationOptions;
|
|
21
|
+
weights?: Record<string, number>;
|
|
22
|
+
defaultLanguage?: string;
|
|
23
|
+
languageOverride?: string;
|
|
24
|
+
wildcardProjection?: Document;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface NormalizedIndex {
|
|
28
|
+
name: string;
|
|
29
|
+
key: Record<string, MongoIndexDirection>;
|
|
30
|
+
keyEntries: [string, MongoIndexDirection][];
|
|
31
|
+
isText: boolean;
|
|
32
|
+
options: NormalizedIndexOptions;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface NormalizedDeclaredIndex extends NormalizedIndex {
|
|
36
|
+
description: IndexDescription;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const buildDescription = (
|
|
40
|
+
index: MongoIndex<any>,
|
|
41
|
+
name: string,
|
|
42
|
+
): IndexDescription => {
|
|
43
|
+
const description: IndexDescription = {
|
|
44
|
+
key: index.key as Record<string, MongoIndexDirection>,
|
|
45
|
+
name,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
if (index.unique !== undefined) description.unique = index.unique;
|
|
49
|
+
if (index.sparse !== undefined) description.sparse = index.sparse;
|
|
50
|
+
if (index.hidden !== undefined) description.hidden = index.hidden;
|
|
51
|
+
if (index.expireAfterSeconds !== undefined) {
|
|
52
|
+
description.expireAfterSeconds = index.expireAfterSeconds;
|
|
53
|
+
}
|
|
54
|
+
if (index.partialFilterExpression) {
|
|
55
|
+
description.partialFilterExpression = index.partialFilterExpression;
|
|
56
|
+
}
|
|
57
|
+
if (index.collation) description.collation = index.collation;
|
|
58
|
+
if (index.weights) description.weights = index.weights;
|
|
59
|
+
if (index.default_language) {
|
|
60
|
+
// eslint-disable-next-line camelcase -- mongo option name
|
|
61
|
+
description.default_language = index.default_language;
|
|
62
|
+
}
|
|
63
|
+
if (index.language_override) {
|
|
64
|
+
// eslint-disable-next-line camelcase -- mongo option name
|
|
65
|
+
description.language_override = index.language_override;
|
|
66
|
+
}
|
|
67
|
+
if (index.wildcardProjection) {
|
|
68
|
+
description.wildcardProjection = index.wildcardProjection;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return description;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export const normalizeDeclaredIndex = <Model extends MongoBaseModel<any>>(
|
|
75
|
+
index: MongoIndex<Model>,
|
|
76
|
+
): NormalizedDeclaredIndex => {
|
|
77
|
+
const key = index.key as Record<string, MongoIndexDirection>;
|
|
78
|
+
const entries = Object.entries(key);
|
|
79
|
+
|
|
80
|
+
if (entries.length === 0) {
|
|
81
|
+
throw new Error("Invalid index: key must have at least one field");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const textFields = entries
|
|
85
|
+
.filter(([, direction]) => direction === "text")
|
|
86
|
+
.map(([field]) => field);
|
|
87
|
+
const isText = textFields.length > 0;
|
|
88
|
+
|
|
89
|
+
const name = index.name ?? buildIndexName(key);
|
|
90
|
+
const isIdOnly = entries.length === 1 && entries[0]![0] === "_id";
|
|
91
|
+
if (name === "_id_" || isIdOnly) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
"Invalid index: _id is always indexed by mongo and cannot be declared",
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const weights = isText
|
|
98
|
+
? {
|
|
99
|
+
...Object.fromEntries(textFields.map((field) => [field, 1])),
|
|
100
|
+
...index.weights,
|
|
101
|
+
}
|
|
102
|
+
: undefined;
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
name,
|
|
106
|
+
key,
|
|
107
|
+
keyEntries: entries.filter(([, direction]) => direction !== "text"),
|
|
108
|
+
isText,
|
|
109
|
+
options: {
|
|
110
|
+
unique: index.unique ?? false,
|
|
111
|
+
sparse: index.sparse ?? false,
|
|
112
|
+
hidden: index.hidden ?? false,
|
|
113
|
+
expireAfterSeconds: index.expireAfterSeconds,
|
|
114
|
+
partialFilterExpression: index.partialFilterExpression,
|
|
115
|
+
collation: index.collation,
|
|
116
|
+
weights,
|
|
117
|
+
defaultLanguage: isText
|
|
118
|
+
? (index.default_language ?? textDefaultLanguage)
|
|
119
|
+
: undefined,
|
|
120
|
+
languageOverride: isText
|
|
121
|
+
? (index.language_override ?? textLanguageOverride)
|
|
122
|
+
: undefined,
|
|
123
|
+
wildcardProjection: index.wildcardProjection,
|
|
124
|
+
},
|
|
125
|
+
description: buildDescription(index, name),
|
|
126
|
+
};
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
export const normalizeExistingIndex = (
|
|
130
|
+
existing: IndexDescriptionInfo,
|
|
131
|
+
): NormalizedIndex => {
|
|
132
|
+
const key = existing.key as Record<string, MongoIndexDirection>;
|
|
133
|
+
const entries = Object.entries(key);
|
|
134
|
+
const isText = entries.some(([field]) => field === "_fts");
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
name: existing.name ?? buildIndexName(key),
|
|
138
|
+
key,
|
|
139
|
+
keyEntries: entries.filter(
|
|
140
|
+
([field]) => field !== "_fts" && field !== "_ftsx",
|
|
141
|
+
),
|
|
142
|
+
isText,
|
|
143
|
+
options: {
|
|
144
|
+
unique: existing.unique ?? false,
|
|
145
|
+
sparse: existing.sparse ?? false,
|
|
146
|
+
hidden: existing.hidden ?? false,
|
|
147
|
+
expireAfterSeconds: existing.expireAfterSeconds,
|
|
148
|
+
partialFilterExpression: existing.partialFilterExpression,
|
|
149
|
+
collation: existing.collation,
|
|
150
|
+
weights: isText ? existing.weights : undefined,
|
|
151
|
+
defaultLanguage: isText
|
|
152
|
+
? (existing.default_language ?? textDefaultLanguage)
|
|
153
|
+
: undefined,
|
|
154
|
+
languageOverride: isText
|
|
155
|
+
? (existing.language_override ?? textLanguageOverride)
|
|
156
|
+
: undefined,
|
|
157
|
+
wildcardProjection: existing.wildcardProjection,
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
interface NormalizeDeclaredIndexesParams<Model extends MongoBaseModel<any>> {
|
|
163
|
+
collectionName: string;
|
|
164
|
+
indexes: readonly MongoIndex<Model>[];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export const normalizeDeclaredIndexes = <Model extends MongoBaseModel<any>>({
|
|
168
|
+
collectionName,
|
|
169
|
+
indexes,
|
|
170
|
+
}: NormalizeDeclaredIndexesParams<Model>): NormalizedDeclaredIndex[] => {
|
|
171
|
+
const normalized = indexes.map((index) => normalizeDeclaredIndex(index));
|
|
172
|
+
|
|
173
|
+
const names = new Set<string>();
|
|
174
|
+
normalized.forEach(({ name }) => {
|
|
175
|
+
if (names.has(name)) {
|
|
176
|
+
throw new Error(
|
|
177
|
+
`Duplicate index name "${name}" declared on collection "${collectionName}"`,
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
names.add(name);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const textIndexes = normalized.filter(({ isText }) => isText);
|
|
184
|
+
if (textIndexes.length > 1) {
|
|
185
|
+
throw new Error(
|
|
186
|
+
`Only one text index is allowed per collection, "${collectionName}" declares ${textIndexes.length}`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return normalized;
|
|
191
|
+
};
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { beforeEach, describe, it } from "node:test";
|
|
3
|
+
import MongoRegistry from "../MongoRegistry.ts";
|
|
4
|
+
import type { MongoRegistryStore } from "../MongoRegistryStore.ts";
|
|
5
|
+
import { runIndexesCli } from "./runIndexesCli.ts";
|
|
6
|
+
import type {
|
|
7
|
+
MongoIndexPlan,
|
|
8
|
+
MongoIndexSyncResult,
|
|
9
|
+
SyncIndexesOptions,
|
|
10
|
+
} from "./types.ts";
|
|
11
|
+
|
|
12
|
+
const collectionName = "tasks";
|
|
13
|
+
|
|
14
|
+
interface CreatePlanParams {
|
|
15
|
+
empty: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const createPlan = ({ empty }: CreatePlanParams): MongoIndexPlan => ({
|
|
19
|
+
collectionName,
|
|
20
|
+
toCreate: empty
|
|
21
|
+
? []
|
|
22
|
+
: [{ name: "completed_1", index: { key: { completed: 1 } } }],
|
|
23
|
+
toRecreate: [],
|
|
24
|
+
toCollMod: [],
|
|
25
|
+
toDrop: [],
|
|
26
|
+
unchanged: [],
|
|
27
|
+
undeclaredKept: [],
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
let receivedOptions: SyncIndexesOptions[];
|
|
31
|
+
let logs: string[];
|
|
32
|
+
let errors: string[];
|
|
33
|
+
|
|
34
|
+
interface CreateRegistryParams {
|
|
35
|
+
empty: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const createRegistry = ({ empty }: CreateRegistryParams): MongoRegistry => {
|
|
39
|
+
const store: MongoRegistryStore = {
|
|
40
|
+
collectionName,
|
|
41
|
+
planIndexes: (options = {}) => {
|
|
42
|
+
receivedOptions.push(options);
|
|
43
|
+
return Promise.resolve(createPlan({ empty }));
|
|
44
|
+
},
|
|
45
|
+
syncIndexes: (options = {}) => {
|
|
46
|
+
receivedOptions.push(options);
|
|
47
|
+
return Promise.resolve<MongoIndexSyncResult>({
|
|
48
|
+
collectionName,
|
|
49
|
+
plan: createPlan({ empty }),
|
|
50
|
+
dryRun: options.dryRun ?? false,
|
|
51
|
+
created: empty ? [] : ["completed_1"],
|
|
52
|
+
dropped: [],
|
|
53
|
+
modified: [],
|
|
54
|
+
});
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
return new MongoRegistry([store]);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
interface RunParams {
|
|
62
|
+
argv: string[];
|
|
63
|
+
empty?: boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const run = ({ argv, empty = false }: RunParams): Promise<number> =>
|
|
67
|
+
runIndexesCli({
|
|
68
|
+
registry: createRegistry({ empty }),
|
|
69
|
+
argv,
|
|
70
|
+
log: (message) => logs.push(message),
|
|
71
|
+
logError: (message) => errors.push(message),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("runIndexesCli", () => {
|
|
75
|
+
beforeEach(() => {
|
|
76
|
+
receivedOptions = [];
|
|
77
|
+
logs = [];
|
|
78
|
+
errors = [];
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("plans by default and does not sync", async () => {
|
|
82
|
+
assert.equal(await run({ argv: [] }), 0);
|
|
83
|
+
assert.match(logs.join("\n"), /\+ create {2}completed_1/);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("exits 1 with --check when the plan is not empty", async () => {
|
|
87
|
+
assert.equal(await run({ argv: ["plan", "--check"] }), 1);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("exits 0 with --check when the plan is empty", async () => {
|
|
91
|
+
assert.equal(await run({ argv: ["plan", "--check"], empty: true }), 0);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("syncs and reports what it did", async () => {
|
|
95
|
+
assert.equal(await run({ argv: ["sync"] }), 0);
|
|
96
|
+
assert.match(logs.join("\n"), /tasks: 1 created, 0 modified, 0 dropped$/);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("passes --dry-run to the registry and says nothing was applied", async () => {
|
|
100
|
+
assert.equal(await run({ argv: ["sync", "--dry-run"] }), 0);
|
|
101
|
+
assert.deepEqual(receivedOptions, [
|
|
102
|
+
{ dryRun: true, dropUndeclaredIndexes: true },
|
|
103
|
+
]);
|
|
104
|
+
assert.match(logs.join("\n"), /\(dry run, nothing applied\)/);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("passes --keep-undeclared to the registry", async () => {
|
|
108
|
+
assert.equal(await run({ argv: ["sync", "--keep-undeclared"] }), 0);
|
|
109
|
+
assert.deepEqual(receivedOptions, [
|
|
110
|
+
{ dryRun: false, dropUndeclaredIndexes: false },
|
|
111
|
+
]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("returns 2 and prints the usage on an unknown command", async () => {
|
|
115
|
+
assert.equal(await run({ argv: ["migrate"] }), 2);
|
|
116
|
+
assert.match(errors.join("\n"), /Unknown command "migrate"/);
|
|
117
|
+
assert.equal(logs.length, 0);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type MongoConnection from "../MongoConnection.ts";
|
|
2
|
+
import type MongoRegistry from "../MongoRegistry.ts";
|
|
3
|
+
import { isMongoIndexPlanEmpty } from "./diffIndexes.ts";
|
|
4
|
+
import { formatIndexPlans } from "./formatIndexPlan.ts";
|
|
5
|
+
import type { MongoIndexSyncResult, SyncIndexesOptions } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
const usage = `Usage: [plan|sync] [options]
|
|
8
|
+
|
|
9
|
+
plan print the plan, changes nothing (default)
|
|
10
|
+
sync apply the plan
|
|
11
|
+
|
|
12
|
+
--check with plan, exit 1 when the plan is not empty
|
|
13
|
+
--dry-run with sync, compute everything but apply nothing
|
|
14
|
+
--keep-undeclared do not drop indexes that are not declared`;
|
|
15
|
+
|
|
16
|
+
const formatSyncResult = ({
|
|
17
|
+
collectionName,
|
|
18
|
+
created,
|
|
19
|
+
modified,
|
|
20
|
+
dropped,
|
|
21
|
+
}: MongoIndexSyncResult): string =>
|
|
22
|
+
`${collectionName}: ${created.length} created, ${modified.length} modified, ${dropped.length} dropped`;
|
|
23
|
+
|
|
24
|
+
export interface RunIndexesCliParams {
|
|
25
|
+
registry: MongoRegistry;
|
|
26
|
+
argv?: readonly string[];
|
|
27
|
+
connection?: MongoConnection;
|
|
28
|
+
log?: (message: string) => void;
|
|
29
|
+
logError?: (message: string) => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const runIndexesCli = async ({
|
|
33
|
+
registry,
|
|
34
|
+
argv = process.argv.slice(2),
|
|
35
|
+
connection,
|
|
36
|
+
log = console.log,
|
|
37
|
+
logError = console.error,
|
|
38
|
+
}: RunIndexesCliParams): Promise<number> => {
|
|
39
|
+
const command = argv.find((arg) => !arg.startsWith("--")) ?? "plan";
|
|
40
|
+
const options: SyncIndexesOptions = {
|
|
41
|
+
...(argv.includes("--keep-undeclared")
|
|
42
|
+
? { dropUndeclaredIndexes: false }
|
|
43
|
+
: {}),
|
|
44
|
+
dryRun: argv.includes("--dry-run"),
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
if (command === "plan") {
|
|
49
|
+
const plans = await registry.planIndexes(options);
|
|
50
|
+
log(formatIndexPlans(plans));
|
|
51
|
+
const hasChanges = !plans.every((plan) => isMongoIndexPlanEmpty(plan));
|
|
52
|
+
return argv.includes("--check") && hasChanges ? 1 : 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (command === "sync") {
|
|
56
|
+
const results = await registry.syncIndexes(options);
|
|
57
|
+
log(formatIndexPlans(results.map(({ plan }) => plan)));
|
|
58
|
+
log(
|
|
59
|
+
results
|
|
60
|
+
.map(
|
|
61
|
+
(result) =>
|
|
62
|
+
`${formatSyncResult(result)}${result.dryRun ? " (dry run, nothing applied)" : ""}`,
|
|
63
|
+
)
|
|
64
|
+
.join("\n"),
|
|
65
|
+
);
|
|
66
|
+
return 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
logError(`Unknown command "${command}".\n\n${usage}`);
|
|
70
|
+
return 2;
|
|
71
|
+
} finally {
|
|
72
|
+
await connection?.close();
|
|
73
|
+
}
|
|
74
|
+
};
|