liwi-mongo 13.1.0 → 13.2.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.
Files changed (71) hide show
  1. package/README.md +245 -3
  2. package/dist/definitions/MongoConnection.d.ts.map +1 -1
  3. package/dist/definitions/MongoCursor.d.ts.map +1 -1
  4. package/dist/definitions/MongoQueryCollection.d.ts.map +1 -1
  5. package/dist/definitions/MongoQuerySingleItem.d.ts.map +1 -1
  6. package/dist/definitions/MongoRegistry.d.ts +20 -0
  7. package/dist/definitions/MongoRegistry.d.ts.map +1 -0
  8. package/dist/definitions/MongoRegistry.test.d.ts +2 -0
  9. package/dist/definitions/MongoRegistry.test.d.ts.map +1 -0
  10. package/dist/definitions/MongoRegistryStore.d.ts +7 -0
  11. package/dist/definitions/MongoRegistryStore.d.ts.map +1 -0
  12. package/dist/definitions/MongoStore.d.ts +9 -1
  13. package/dist/definitions/MongoStore.d.ts.map +1 -1
  14. package/dist/definitions/MongoStoreIndexes.test.d.ts +2 -0
  15. package/dist/definitions/MongoStoreIndexes.test.d.ts.map +1 -0
  16. package/dist/definitions/index.d.ts +8 -1
  17. package/dist/definitions/index.d.ts.map +1 -1
  18. package/dist/definitions/indexes/applyIndexPlan.d.ts +11 -0
  19. package/dist/definitions/indexes/applyIndexPlan.d.ts.map +1 -0
  20. package/dist/definitions/indexes/buildIndexName.d.ts +3 -0
  21. package/dist/definitions/indexes/buildIndexName.d.ts.map +1 -0
  22. package/dist/definitions/indexes/buildIndexName.test.d.ts +2 -0
  23. package/dist/definitions/indexes/buildIndexName.test.d.ts.map +1 -0
  24. package/dist/definitions/indexes/deepEqualUnordered.d.ts +2 -0
  25. package/dist/definitions/indexes/deepEqualUnordered.d.ts.map +1 -0
  26. package/dist/definitions/indexes/diffIndexes.d.ts +13 -0
  27. package/dist/definitions/indexes/diffIndexes.d.ts.map +1 -0
  28. package/dist/definitions/indexes/diffIndexes.test.d.ts +2 -0
  29. package/dist/definitions/indexes/diffIndexes.test.d.ts.map +1 -0
  30. package/dist/definitions/indexes/formatIndexPlan.d.ts +5 -0
  31. package/dist/definitions/indexes/formatIndexPlan.d.ts.map +1 -0
  32. package/dist/definitions/indexes/formatIndexPlan.test.d.ts +2 -0
  33. package/dist/definitions/indexes/formatIndexPlan.test.d.ts.map +1 -0
  34. package/dist/definitions/indexes/isIndexConflictError.d.ts +4 -0
  35. package/dist/definitions/indexes/isIndexConflictError.d.ts.map +1 -0
  36. package/dist/definitions/indexes/normalizeIndex.d.ts +34 -0
  37. package/dist/definitions/indexes/normalizeIndex.d.ts.map +1 -0
  38. package/dist/definitions/indexes/normalizeIndex.test.d.ts +2 -0
  39. package/dist/definitions/indexes/normalizeIndex.test.d.ts.map +1 -0
  40. package/dist/definitions/indexes/runIndexesCli.d.ts +11 -0
  41. package/dist/definitions/indexes/runIndexesCli.d.ts.map +1 -0
  42. package/dist/definitions/indexes/runIndexesCli.test.d.ts +2 -0
  43. package/dist/definitions/indexes/runIndexesCli.test.d.ts.map +1 -0
  44. package/dist/definitions/indexes/syncIndexes.integration.test.d.ts +2 -0
  45. package/dist/definitions/indexes/syncIndexes.integration.test.d.ts.map +1 -0
  46. package/dist/definitions/indexes/types.d.ts +72 -0
  47. package/dist/definitions/indexes/types.d.ts.map +1 -0
  48. package/dist/index-node.mjs +626 -10
  49. package/dist/index-node.mjs.map +1 -1
  50. package/package.json +10 -7
  51. package/src/MongoRegistry.test.ts +136 -0
  52. package/src/MongoRegistry.ts +108 -0
  53. package/src/MongoRegistryStore.ts +11 -0
  54. package/src/MongoStore.ts +63 -1
  55. package/src/MongoStoreIndexes.test.ts +259 -0
  56. package/src/index.ts +26 -1
  57. package/src/indexes/applyIndexPlan.ts +149 -0
  58. package/src/indexes/buildIndexName.test.ts +34 -0
  59. package/src/indexes/buildIndexName.ts +8 -0
  60. package/src/indexes/deepEqualUnordered.ts +36 -0
  61. package/src/indexes/diffIndexes.test.ts +290 -0
  62. package/src/indexes/diffIndexes.ts +234 -0
  63. package/src/indexes/formatIndexPlan.test.ts +105 -0
  64. package/src/indexes/formatIndexPlan.ts +71 -0
  65. package/src/indexes/isIndexConflictError.ts +20 -0
  66. package/src/indexes/normalizeIndex.test.ts +154 -0
  67. package/src/indexes/normalizeIndex.ts +191 -0
  68. package/src/indexes/runIndexesCli.test.ts +119 -0
  69. package/src/indexes/runIndexesCli.ts +74 -0
  70. package/src/indexes/syncIndexes.integration.test.ts +182 -0
  71. package/src/indexes/types.ts +115 -0
@@ -0,0 +1,290 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import type { IndexDescriptionInfo } from "mongodb";
4
+ import type { MongoBaseModel } from "../MongoBaseModel.ts";
5
+ import { diffIndexes, isMongoIndexPlanEmpty } from "./diffIndexes.ts";
6
+ import type { MongoIndex, MongoIndexPlan } from "./types.ts";
7
+
8
+ interface TestModel extends MongoBaseModel {
9
+ completed: boolean;
10
+ label: string;
11
+ status: string;
12
+ sessionEnd: Date;
13
+ }
14
+
15
+ const idIndex: IndexDescriptionInfo = {
16
+ v: 2,
17
+ key: { _id: 1 },
18
+ name: "_id_",
19
+ };
20
+
21
+ interface PlanParams {
22
+ declaredIndexes?: readonly MongoIndex<TestModel>[];
23
+ existingIndexes?: readonly IndexDescriptionInfo[];
24
+ dropUndeclaredIndexes?: boolean;
25
+ }
26
+
27
+ const plan = ({
28
+ declaredIndexes = [],
29
+ existingIndexes = [idIndex],
30
+ dropUndeclaredIndexes = true,
31
+ }: PlanParams): MongoIndexPlan =>
32
+ diffIndexes<TestModel>({
33
+ collectionName: "tasks",
34
+ declaredIndexes,
35
+ existingIndexes,
36
+ dropUndeclaredIndexes,
37
+ });
38
+
39
+ describe("diffIndexes", () => {
40
+ it("creates every declared index on a fresh collection", () => {
41
+ const result = plan({
42
+ declaredIndexes: [{ key: { completed: 1, created: 1 } }],
43
+ });
44
+
45
+ assert.deepEqual(
46
+ result.toCreate.map(({ name }) => name),
47
+ ["completed_1_created_1"],
48
+ );
49
+ assert.deepEqual(result.toDrop, []);
50
+ assert.equal(isMongoIndexPlanEmpty(result), false);
51
+ });
52
+
53
+ it("never drops or reports the _id index", () => {
54
+ const result = plan({});
55
+
56
+ assert.deepEqual(result.toDrop, []);
57
+ assert.deepEqual(result.unchanged, []);
58
+ assert.equal(isMongoIndexPlanEmpty(result), true);
59
+ });
60
+
61
+ it("ignores server managed fields when comparing", () => {
62
+ const result = plan({
63
+ declaredIndexes: [{ key: { completed: 1 } }],
64
+ existingIndexes: [
65
+ idIndex,
66
+ {
67
+ v: 2,
68
+ key: { completed: 1 },
69
+ name: "completed_1",
70
+ ns: "test.tasks",
71
+ background: true,
72
+ textIndexVersion: 3,
73
+ },
74
+ ],
75
+ });
76
+
77
+ assert.deepEqual(result.unchanged, ["completed_1"]);
78
+ assert.equal(isMongoIndexPlanEmpty(result), true);
79
+ });
80
+
81
+ it("recreates when the key order changes", () => {
82
+ const result = plan({
83
+ declaredIndexes: [
84
+ { key: { completed: 1, created: 1 }, name: "compound" },
85
+ ],
86
+ existingIndexes: [
87
+ idIndex,
88
+ { v: 2, key: { created: 1, completed: 1 }, name: "compound" },
89
+ ],
90
+ });
91
+
92
+ assert.deepEqual(
93
+ result.toRecreate.map(({ name, differences }) => [
94
+ name,
95
+ differences[0]!.field,
96
+ ]),
97
+ [["compound", "key"]],
98
+ );
99
+ });
100
+
101
+ it("recreates when a direction changes", () => {
102
+ const result = plan({
103
+ declaredIndexes: [{ key: { created: -1 }, name: "created" }],
104
+ existingIndexes: [
105
+ idIndex,
106
+ { v: 2, key: { created: 1 }, name: "created" },
107
+ ],
108
+ });
109
+
110
+ assert.equal(result.toRecreate.length, 1);
111
+ });
112
+
113
+ it("recreates when unique is added", () => {
114
+ const result = plan({
115
+ declaredIndexes: [{ key: { label: 1 }, unique: true }],
116
+ existingIndexes: [idIndex, { v: 2, key: { label: 1 }, name: "label_1" }],
117
+ });
118
+
119
+ assert.deepEqual(
120
+ result.toRecreate.map(({ differences }) => differences[0]!.field),
121
+ ["unique"],
122
+ );
123
+ });
124
+
125
+ it("uses collMod when an existing ttl value changes", () => {
126
+ const result = plan({
127
+ declaredIndexes: [{ key: { sessionEnd: 1 }, expireAfterSeconds: 60 }],
128
+ existingIndexes: [
129
+ idIndex,
130
+ {
131
+ v: 2,
132
+ key: { sessionEnd: 1 },
133
+ name: "sessionEnd_1",
134
+ expireAfterSeconds: 3600,
135
+ },
136
+ ],
137
+ });
138
+
139
+ assert.deepEqual(result.toRecreate, []);
140
+ assert.deepEqual(
141
+ result.toCollMod.map(({ name, changes }) => [name, changes]),
142
+ [["sessionEnd_1", { expireAfterSeconds: 60 }]],
143
+ );
144
+ });
145
+
146
+ it("recreates when ttl is added to a non ttl index", () => {
147
+ const result = plan({
148
+ declaredIndexes: [{ key: { sessionEnd: 1 }, expireAfterSeconds: 60 }],
149
+ existingIndexes: [
150
+ idIndex,
151
+ { v: 2, key: { sessionEnd: 1 }, name: "sessionEnd_1" },
152
+ ],
153
+ });
154
+
155
+ assert.deepEqual(result.toCollMod, []);
156
+ assert.equal(result.toRecreate.length, 1);
157
+ });
158
+
159
+ it("uses collMod when hidden is toggled", () => {
160
+ const result = plan({
161
+ declaredIndexes: [{ key: { label: 1 }, hidden: true }],
162
+ existingIndexes: [idIndex, { v: 2, key: { label: 1 }, name: "label_1" }],
163
+ });
164
+
165
+ assert.deepEqual(
166
+ result.toCollMod.map(({ changes }) => changes),
167
+ [{ hidden: true }],
168
+ );
169
+ });
170
+
171
+ it("drops undeclared indexes by default", () => {
172
+ const result = plan({
173
+ existingIndexes: [
174
+ idIndex,
175
+ { v: 2, key: { status: 1 }, name: "legacy_status_1" },
176
+ ],
177
+ });
178
+
179
+ assert.deepEqual(
180
+ result.toDrop.map(({ name }) => name),
181
+ ["legacy_status_1"],
182
+ );
183
+ assert.deepEqual(result.undeclaredKept, []);
184
+ });
185
+
186
+ it("keeps undeclared indexes when dropUndeclaredIndexes is false", () => {
187
+ const result = plan({
188
+ existingIndexes: [
189
+ idIndex,
190
+ { v: 2, key: { status: 1 }, name: "legacy_status_1" },
191
+ ],
192
+ dropUndeclaredIndexes: false,
193
+ });
194
+
195
+ assert.deepEqual(result.toDrop, []);
196
+ assert.deepEqual(result.undeclaredKept, ["legacy_status_1"]);
197
+ assert.equal(isMongoIndexPlanEmpty(result), true);
198
+ });
199
+
200
+ it("treats a reordered partialFilterExpression as unchanged", () => {
201
+ const result = plan({
202
+ declaredIndexes: [
203
+ {
204
+ key: { label: 1 },
205
+ partialFilterExpression: { completed: true, status: "open" },
206
+ },
207
+ ],
208
+ existingIndexes: [
209
+ idIndex,
210
+ {
211
+ v: 2,
212
+ key: { label: 1 },
213
+ name: "label_1",
214
+ partialFilterExpression: { status: "open", completed: true },
215
+ },
216
+ ],
217
+ });
218
+
219
+ assert.deepEqual(result.unchanged, ["label_1"]);
220
+ });
221
+
222
+ it("compares only the declared collation keys", () => {
223
+ const result = plan({
224
+ declaredIndexes: [{ key: { label: 1 }, collation: { locale: "en" } }],
225
+ existingIndexes: [
226
+ idIndex,
227
+ {
228
+ v: 2,
229
+ key: { label: 1 },
230
+ name: "label_1",
231
+ collation: {
232
+ locale: "en",
233
+ caseLevel: false,
234
+ caseFirst: "off",
235
+ strength: 3,
236
+ numericOrdering: false,
237
+ alternate: "non-ignorable",
238
+ maxVariable: "punct",
239
+ normalization: false,
240
+ backwards: false,
241
+ },
242
+ },
243
+ ],
244
+ });
245
+
246
+ assert.deepEqual(result.unchanged, ["label_1"]);
247
+ });
248
+
249
+ it("treats a text index stored as _fts/_ftsx as unchanged", () => {
250
+ const result = plan({
251
+ declaredIndexes: [{ key: { label: "text" } }],
252
+ existingIndexes: [
253
+ idIndex,
254
+ {
255
+ v: 2,
256
+ key: { _fts: "text", _ftsx: 1 },
257
+ name: "label_text",
258
+ weights: { label: 1 },
259
+ // eslint-disable-next-line camelcase -- mongo option name
260
+ default_language: "english",
261
+ // eslint-disable-next-line camelcase -- mongo option name
262
+ language_override: "language",
263
+ textIndexVersion: 3,
264
+ },
265
+ ],
266
+ });
267
+
268
+ assert.deepEqual(result.unchanged, ["label_text"]);
269
+ assert.equal(isMongoIndexPlanEmpty(result), true);
270
+ });
271
+
272
+ it("handles a rename as a drop plus a create", () => {
273
+ const result = plan({
274
+ declaredIndexes: [{ key: { label: 1 }, name: "label_new" }],
275
+ existingIndexes: [
276
+ idIndex,
277
+ { v: 2, key: { label: 1 }, name: "label_old" },
278
+ ],
279
+ });
280
+
281
+ assert.deepEqual(
282
+ result.toCreate.map(({ name }) => name),
283
+ ["label_new"],
284
+ );
285
+ assert.deepEqual(
286
+ result.toDrop.map(({ name }) => name),
287
+ ["label_old"],
288
+ );
289
+ });
290
+ });
@@ -0,0 +1,234 @@
1
+ import type { IndexDescriptionInfo } from "mongodb";
2
+ import type { MongoBaseModel } from "../MongoBaseModel.ts";
3
+ import { deepEqualUnordered } from "./deepEqualUnordered.ts";
4
+ import type { NormalizedIndex } from "./normalizeIndex.ts";
5
+ import {
6
+ normalizeDeclaredIndexes,
7
+ normalizeExistingIndex,
8
+ } from "./normalizeIndex.ts";
9
+ import type {
10
+ MongoIndex,
11
+ MongoIndexCollModChanges,
12
+ MongoIndexDifference,
13
+ MongoIndexPlan,
14
+ } from "./types.ts";
15
+
16
+ const idIndexName = "_id_";
17
+
18
+ const isSameKey = (
19
+ declared: NormalizedIndex,
20
+ existing: NormalizedIndex,
21
+ ): boolean =>
22
+ declared.isText === existing.isText &&
23
+ declared.keyEntries.length === existing.keyEntries.length &&
24
+ declared.keyEntries.every(
25
+ ([field, direction], index) =>
26
+ existing.keyEntries[index]![0] === field &&
27
+ existing.keyEntries[index]![1] === direction,
28
+ );
29
+
30
+ const isSameCollation = (
31
+ declared: NormalizedIndex["options"]["collation"],
32
+ existing: NormalizedIndex["options"]["collation"],
33
+ ): boolean => {
34
+ if (!declared) return !existing;
35
+ if (!existing) return false;
36
+
37
+ return Object.entries(declared).every(([key, value]) =>
38
+ deepEqualUnordered(value, (existing as any)[key]),
39
+ );
40
+ };
41
+
42
+ const diffOptions = (
43
+ declared: NormalizedIndex,
44
+ existing: NormalizedIndex,
45
+ ): MongoIndexDifference[] => {
46
+ const differences: MongoIndexDifference[] = [];
47
+
48
+ const compare = (
49
+ field: MongoIndexDifference["field"],
50
+ declaredValue: unknown,
51
+ existingValue: unknown,
52
+ equals: (a: unknown, b: unknown) => boolean = deepEqualUnordered,
53
+ ): void => {
54
+ if (!equals(declaredValue, existingValue)) {
55
+ differences.push({
56
+ field,
57
+ declared: declaredValue,
58
+ existing: existingValue,
59
+ });
60
+ }
61
+ };
62
+
63
+ compare("unique", declared.options.unique, existing.options.unique);
64
+ compare("sparse", declared.options.sparse, existing.options.sparse);
65
+ compare("hidden", declared.options.hidden, existing.options.hidden);
66
+ compare(
67
+ "expireAfterSeconds",
68
+ declared.options.expireAfterSeconds,
69
+ existing.options.expireAfterSeconds,
70
+ );
71
+ compare(
72
+ "partialFilterExpression",
73
+ declared.options.partialFilterExpression,
74
+ existing.options.partialFilterExpression,
75
+ );
76
+ compare(
77
+ "collation",
78
+ declared.options.collation,
79
+ existing.options.collation,
80
+ (a, b) =>
81
+ isSameCollation(
82
+ a as NormalizedIndex["options"]["collation"],
83
+ b as NormalizedIndex["options"]["collation"],
84
+ ),
85
+ );
86
+ compare("weights", declared.options.weights, existing.options.weights);
87
+ compare(
88
+ "default_language",
89
+ declared.options.defaultLanguage,
90
+ existing.options.defaultLanguage,
91
+ );
92
+ compare(
93
+ "language_override",
94
+ declared.options.languageOverride,
95
+ existing.options.languageOverride,
96
+ );
97
+ compare(
98
+ "wildcardProjection",
99
+ declared.options.wildcardProjection,
100
+ existing.options.wildcardProjection,
101
+ );
102
+
103
+ return differences;
104
+ };
105
+
106
+ const isCollModApplicable = (
107
+ differences: readonly MongoIndexDifference[],
108
+ ): boolean =>
109
+ differences.length > 0 &&
110
+ differences.every(
111
+ (difference) =>
112
+ difference.field === "hidden" ||
113
+ (difference.field === "expireAfterSeconds" &&
114
+ difference.declared !== undefined &&
115
+ difference.existing !== undefined),
116
+ );
117
+
118
+ const buildCollModChanges = (
119
+ differences: readonly MongoIndexDifference[],
120
+ ): MongoIndexCollModChanges => {
121
+ const changes: MongoIndexCollModChanges = {};
122
+ differences.forEach((difference) => {
123
+ if (difference.field === "hidden") {
124
+ changes.hidden = difference.declared as boolean;
125
+ } else if (difference.field === "expireAfterSeconds") {
126
+ changes.expireAfterSeconds = difference.declared as number;
127
+ }
128
+ });
129
+ return changes;
130
+ };
131
+
132
+ interface DiffIndexesParams<Model extends MongoBaseModel<any>> {
133
+ collectionName: string;
134
+ declaredIndexes: readonly MongoIndex<Model>[];
135
+ existingIndexes: readonly IndexDescriptionInfo[];
136
+ dropUndeclaredIndexes: boolean;
137
+ }
138
+
139
+ export const diffIndexes = <Model extends MongoBaseModel<any>>({
140
+ collectionName,
141
+ declaredIndexes,
142
+ existingIndexes,
143
+ dropUndeclaredIndexes,
144
+ }: DiffIndexesParams<Model>): MongoIndexPlan => {
145
+ const declared = normalizeDeclaredIndexes({
146
+ collectionName,
147
+ indexes: declaredIndexes,
148
+ });
149
+
150
+ const remainingExisting = new Map<
151
+ string,
152
+ { normalized: NormalizedIndex; raw: IndexDescriptionInfo }
153
+ >();
154
+ existingIndexes.forEach((raw) => {
155
+ const normalized = normalizeExistingIndex(raw);
156
+ if (normalized.name === idIndexName) return;
157
+ remainingExisting.set(normalized.name, { normalized, raw });
158
+ });
159
+
160
+ const plan: MongoIndexPlan = {
161
+ collectionName,
162
+ toCreate: [],
163
+ toRecreate: [],
164
+ toCollMod: [],
165
+ toDrop: [],
166
+ unchanged: [],
167
+ undeclaredKept: [],
168
+ };
169
+
170
+ declared.forEach((declaredIndex) => {
171
+ const existing = remainingExisting.get(declaredIndex.name);
172
+
173
+ if (!existing) {
174
+ plan.toCreate.push({
175
+ name: declaredIndex.name,
176
+ index: declaredIndex.description,
177
+ });
178
+ return;
179
+ }
180
+
181
+ remainingExisting.delete(declaredIndex.name);
182
+
183
+ if (!isSameKey(declaredIndex, existing.normalized)) {
184
+ plan.toRecreate.push({
185
+ name: declaredIndex.name,
186
+ index: declaredIndex.description,
187
+ existing: existing.raw,
188
+ differences: [
189
+ {
190
+ field: "key",
191
+ declared: declaredIndex.key,
192
+ existing: existing.normalized.key,
193
+ },
194
+ ],
195
+ });
196
+ return;
197
+ }
198
+
199
+ const differences = diffOptions(declaredIndex, existing.normalized);
200
+
201
+ if (differences.length === 0) {
202
+ plan.unchanged.push(declaredIndex.name);
203
+ } else if (isCollModApplicable(differences)) {
204
+ plan.toCollMod.push({
205
+ name: declaredIndex.name,
206
+ changes: buildCollModChanges(differences),
207
+ differences,
208
+ });
209
+ } else {
210
+ plan.toRecreate.push({
211
+ name: declaredIndex.name,
212
+ index: declaredIndex.description,
213
+ existing: existing.raw,
214
+ differences,
215
+ });
216
+ }
217
+ });
218
+
219
+ remainingExisting.forEach(({ raw }, name) => {
220
+ if (dropUndeclaredIndexes) {
221
+ plan.toDrop.push({ name, existing: raw });
222
+ } else {
223
+ plan.undeclaredKept.push(name);
224
+ }
225
+ });
226
+
227
+ return plan;
228
+ };
229
+
230
+ export const isMongoIndexPlanEmpty = (plan: MongoIndexPlan): boolean =>
231
+ plan.toCreate.length === 0 &&
232
+ plan.toRecreate.length === 0 &&
233
+ plan.toCollMod.length === 0 &&
234
+ plan.toDrop.length === 0;
@@ -0,0 +1,105 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { formatIndexPlan, formatIndexPlans } from "./formatIndexPlan.ts";
4
+ import type { MongoIndexPlan } from "./types.ts";
5
+
6
+ const emptyPlan = (collectionName: string): MongoIndexPlan => ({
7
+ collectionName,
8
+ toCreate: [],
9
+ toRecreate: [],
10
+ toCollMod: [],
11
+ toDrop: [],
12
+ unchanged: [],
13
+ undeclaredKept: [],
14
+ });
15
+
16
+ describe("formatIndexPlan", () => {
17
+ it("collapses a plan with nothing to do", () => {
18
+ const plan = { ...emptyPlan("users"), unchanged: ["a_1", "b_1", "c_1"] };
19
+
20
+ assert.equal(
21
+ formatIndexPlan(plan),
22
+ "users\n = 3 unchanged, nothing to do",
23
+ );
24
+ });
25
+
26
+ it("lists every action", () => {
27
+ const plan: MongoIndexPlan = {
28
+ ...emptyPlan("tasks"),
29
+ toCreate: [
30
+ {
31
+ name: "completed_1_created_1",
32
+ index: { key: { completed: 1, created: 1 } },
33
+ },
34
+ ],
35
+ toRecreate: [
36
+ {
37
+ name: "label_text",
38
+ index: { key: { label: "text" } },
39
+ existing: { key: { title: "text" }, name: "label_text" },
40
+ differences: [
41
+ {
42
+ field: "key",
43
+ declared: { label: "text" },
44
+ existing: { title: "text" },
45
+ },
46
+ ],
47
+ },
48
+ ],
49
+ toCollMod: [
50
+ {
51
+ name: "sessions_ttl",
52
+ changes: { expireAfterSeconds: 60 },
53
+ differences: [
54
+ { field: "expireAfterSeconds", declared: 60, existing: 3600 },
55
+ ],
56
+ },
57
+ ],
58
+ toDrop: [
59
+ {
60
+ name: "legacy_status_1",
61
+ existing: { key: { status: 1 }, name: "legacy_status_1" },
62
+ },
63
+ ],
64
+ unchanged: ["a_1", "b_1"],
65
+ };
66
+
67
+ assert.equal(
68
+ formatIndexPlan(plan),
69
+ [
70
+ "tasks",
71
+ ' + create completed_1_created_1 {"completed":1,"created":1}',
72
+ ' ~ recreate label_text key: {"title":"text"} -> {"label":"text"}',
73
+ " ! modify sessions_ttl expireAfterSeconds: 3600 -> 60",
74
+ " - drop legacy_status_1",
75
+ " = 2 unchanged",
76
+ ].join("\n"),
77
+ );
78
+ });
79
+
80
+ it("reports undeclared indexes left in place", () => {
81
+ const plan = {
82
+ ...emptyPlan("tasks"),
83
+ undeclaredKept: ["legacy_status_1"],
84
+ };
85
+
86
+ assert.equal(
87
+ formatIndexPlan(plan),
88
+ "tasks\n ? kept legacy_status_1 undeclared, not dropped",
89
+ );
90
+ });
91
+ });
92
+
93
+ describe("formatIndexPlans", () => {
94
+ it("joins one block per collection", () => {
95
+ assert.equal(
96
+ formatIndexPlans([emptyPlan("tasks"), emptyPlan("users")]),
97
+ [
98
+ "tasks",
99
+ " = 0 unchanged, nothing to do",
100
+ "users",
101
+ " = 0 unchanged, nothing to do",
102
+ ].join("\n"),
103
+ );
104
+ });
105
+ });
@@ -0,0 +1,71 @@
1
+ import type { MongoIndexDifference, MongoIndexPlan } from "./types.ts";
2
+
3
+ const formatDifference = ({
4
+ field,
5
+ declared,
6
+ existing,
7
+ }: MongoIndexDifference): string =>
8
+ `${field}: ${JSON.stringify(existing) ?? "undefined"} -> ${JSON.stringify(declared) ?? "undefined"}`;
9
+
10
+ const formatDifferences = (
11
+ differences: readonly MongoIndexDifference[],
12
+ ): string =>
13
+ differences.map((difference) => formatDifference(difference)).join(", ");
14
+
15
+ interface PlanLine {
16
+ action: string;
17
+ name: string;
18
+ detail: string;
19
+ }
20
+
21
+ const buildLines = (plan: MongoIndexPlan): PlanLine[] => [
22
+ ...plan.toCreate.map(({ name, index }) => ({
23
+ action: "+ create",
24
+ name,
25
+ detail: JSON.stringify(index.key),
26
+ })),
27
+ ...plan.toRecreate.map(({ name, differences }) => ({
28
+ action: "~ recreate",
29
+ name,
30
+ detail: formatDifferences(differences),
31
+ })),
32
+ ...plan.toCollMod.map(({ name, differences }) => ({
33
+ action: "! modify",
34
+ name,
35
+ detail: formatDifferences(differences),
36
+ })),
37
+ ...plan.toDrop.map(({ name }) => ({ action: "- drop", name, detail: "" })),
38
+ ...plan.undeclaredKept.map((name) => ({
39
+ action: "? kept",
40
+ name,
41
+ detail: "undeclared, not dropped",
42
+ })),
43
+ ];
44
+
45
+ export const formatIndexPlan = (plan: MongoIndexPlan): string => {
46
+ const lines = buildLines(plan);
47
+ const unchangedCount = plan.unchanged.length;
48
+
49
+ if (lines.length === 0) {
50
+ return `${plan.collectionName}\n = ${unchangedCount} unchanged, nothing to do`;
51
+ }
52
+
53
+ const actionWidth = Math.max(...lines.map(({ action }) => action.length));
54
+ const nameWidth = Math.max(...lines.map(({ name }) => name.length));
55
+
56
+ const formattedLines = lines.map(
57
+ ({ action, name, detail }) =>
58
+ ` ${action.padEnd(actionWidth)} ${detail ? name.padEnd(nameWidth) : name}${detail ? ` ${detail}` : ""}`,
59
+ );
60
+
61
+ if (unchangedCount > 0) {
62
+ formattedLines.push(` = ${unchangedCount} unchanged`);
63
+ }
64
+
65
+ return [plan.collectionName, ...formattedLines].join("\n");
66
+ };
67
+
68
+ export const formatIndexPlans = (plans: readonly MongoIndexPlan[]): string =>
69
+ plans.map((plan) => formatIndexPlan(plan)).join("\n");
70
+
71
+ export { isMongoIndexPlanEmpty } from "./diffIndexes.ts";