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
package/src/MongoStore.ts CHANGED
@@ -28,6 +28,16 @@ import type MongoConnection from "./MongoConnection.ts";
28
28
  import MongoCursor from "./MongoCursor.ts";
29
29
  import MongoQueryCollection from "./MongoQueryCollection.ts";
30
30
  import MongoQuerySingleItem from "./MongoQuerySingleItem.ts";
31
+ import { applyIndexPlan } from "./indexes/applyIndexPlan.ts";
32
+ import { diffIndexes } from "./indexes/diffIndexes.ts";
33
+ import { isNamespaceNotFoundError } from "./indexes/isIndexConflictError.ts";
34
+ import { normalizeDeclaredIndexes } from "./indexes/normalizeIndex.ts";
35
+ import type {
36
+ MongoIndex,
37
+ MongoIndexPlan,
38
+ MongoIndexSyncResult,
39
+ SyncIndexesOptions,
40
+ } from "./indexes/types.ts";
31
41
 
32
42
  export interface MongoUpsertResult<
33
43
  KeyValue extends AllowedKeyValue,
@@ -37,6 +47,10 @@ export interface MongoUpsertResult<
37
47
  inserted: boolean;
38
48
  }
39
49
 
50
+ export interface MongoStoreOptions<Model extends MongoBaseModel<any>> {
51
+ indexes?: readonly MongoIndex<Model>[];
52
+ }
53
+
40
54
  export default class MongoStore<
41
55
  Model extends MongoBaseModel<KeyValue>,
42
56
  KeyValue extends AllowedKeyValue = Model[MongoKeyPath],
@@ -51,15 +65,27 @@ export default class MongoStore<
51
65
 
52
66
  readonly connection: MongoConnection;
53
67
 
68
+ readonly collectionName: string;
69
+
70
+ private readonly declaredIndexes: readonly MongoIndex<Model>[];
71
+
54
72
  private _collection: Collection<Model> | Promise<Collection<Model>>;
55
73
 
56
- constructor(connection: MongoConnection, collectionName: string) {
74
+ constructor(
75
+ connection: MongoConnection,
76
+ collectionName: string,
77
+ { indexes = [] }: MongoStoreOptions<Model> = {},
78
+ ) {
57
79
  this.connection = connection;
58
80
 
59
81
  if (!collectionName) {
60
82
  throw new Error(`Invalid collectionName: "${collectionName}"`);
61
83
  }
62
84
 
85
+ this.collectionName = collectionName;
86
+ this.declaredIndexes = indexes;
87
+ normalizeDeclaredIndexes({ collectionName, indexes });
88
+
63
89
  this._collection = connection.getConnection().then(
64
90
  (client: MongoClient) => {
65
91
  this._collection = client.db().collection(collectionName);
@@ -80,6 +106,42 @@ export default class MongoStore<
80
106
  return Promise.resolve(this._collection);
81
107
  }
82
108
 
109
+ async planIndexes({
110
+ dropUndeclaredIndexes = true,
111
+ }: SyncIndexesOptions = {}): Promise<MongoIndexPlan> {
112
+ const collection = await this.collection;
113
+
114
+ const existingIndexes = await collection
115
+ .listIndexes()
116
+ .toArray()
117
+ .catch((error: unknown) => {
118
+ if (isNamespaceNotFoundError(error)) return [];
119
+ throw error;
120
+ });
121
+
122
+ return diffIndexes<Model>({
123
+ collectionName: this.collectionName,
124
+ declaredIndexes: this.declaredIndexes,
125
+ existingIndexes,
126
+ dropUndeclaredIndexes,
127
+ });
128
+ }
129
+
130
+ async syncIndexes(
131
+ options: SyncIndexesOptions = {},
132
+ ): Promise<MongoIndexSyncResult> {
133
+ const plan = await this.planIndexes(options);
134
+ const collection = await this.collection;
135
+ const client = await this.connection.getConnection();
136
+
137
+ return applyIndexPlan({
138
+ plan,
139
+ collection: collection as Collection<any>,
140
+ db: client.db(),
141
+ dryRun: options.dryRun ?? false,
142
+ });
143
+ }
144
+
83
145
  createQuerySingleItem<
84
146
  Result extends Record<MongoKeyPath, KeyValue> = Model,
85
147
  Params extends QueryParams<Params> = never,
@@ -0,0 +1,259 @@
1
+ import assert from "node:assert/strict";
2
+ import { beforeEach, describe, it, mock } from "node:test";
3
+ import type { IndexDescription, IndexDescriptionInfo } from "mongodb";
4
+ import type { MongoBaseModel } from "./MongoBaseModel.ts";
5
+ import type { MongoStoreOptions } from "./MongoStore.ts";
6
+
7
+ interface TestModel extends MongoBaseModel {
8
+ completed: boolean;
9
+ label: string;
10
+ status: string;
11
+ }
12
+
13
+ const idIndex: IndexDescriptionInfo = { v: 2, key: { _id: 1 }, name: "_id_" };
14
+
15
+ let calls: string[];
16
+ let existingIndexes: IndexDescriptionInfo[];
17
+ let createIndexesImplementation: (
18
+ descriptions: IndexDescription[],
19
+ ) => Promise<unknown>;
20
+
21
+ const collection = {
22
+ find: () => ({ toArray: () => Promise.resolve([]) }),
23
+ countDocuments: () => Promise.resolve(0),
24
+ listIndexes: () => ({
25
+ toArray: () => {
26
+ calls.push("listIndexes");
27
+ return Promise.resolve(existingIndexes);
28
+ },
29
+ }),
30
+ createIndexes: (descriptions: IndexDescription[]) => {
31
+ calls.push(
32
+ `createIndexes:${descriptions.map(({ name }) => name).join(",")}`,
33
+ );
34
+ return createIndexesImplementation(descriptions);
35
+ },
36
+ dropIndex: (name: string) => {
37
+ calls.push(`dropIndex:${name}`);
38
+ return Promise.resolve({});
39
+ },
40
+ };
41
+
42
+ const db = {
43
+ collection: () => collection,
44
+ command: (command: Record<string, unknown>) => {
45
+ calls.push(`command:${JSON.stringify(command)}`);
46
+ return Promise.resolve({});
47
+ },
48
+ };
49
+
50
+ // eslint-disable-next-line n/no-unsupported-features/node-builtins
51
+ mock.module("mongodb", {
52
+ // @ts-expect-error: not yet typed
53
+ exports: {
54
+ default: {
55
+ MongoClient: {
56
+ connect: () =>
57
+ Promise.resolve({
58
+ on: () => undefined,
59
+ db: () => db,
60
+ }),
61
+ },
62
+ ObjectId: class {
63
+ toString(): string {
64
+ return "generated-id";
65
+ }
66
+ },
67
+ },
68
+ },
69
+ });
70
+
71
+ const { default: MongoConnection } = await import("./MongoConnection.ts");
72
+ const { default: MongoStore } = await import("./MongoStore.ts");
73
+ const { default: MongoRegistry } = await import("./MongoRegistry.ts");
74
+
75
+ const createStore = (
76
+ options?: MongoStoreOptions<TestModel>,
77
+ ): InstanceType<typeof MongoStore<TestModel>> =>
78
+ new MongoStore<TestModel>(
79
+ new MongoConnection({ database: "test" }),
80
+ "tasks",
81
+ options,
82
+ );
83
+
84
+ describe("MongoStore indexes", () => {
85
+ beforeEach(() => {
86
+ calls = [];
87
+ existingIndexes = [idIndex];
88
+ createIndexesImplementation = () => Promise.resolve(["ok"]);
89
+ });
90
+
91
+ it("validates declared indexes in the constructor", () => {
92
+ assert.throws(
93
+ () =>
94
+ createStore({
95
+ indexes: [{ key: { label: 1 } }, { key: { label: 1 } }],
96
+ }),
97
+ /Duplicate index name/,
98
+ );
99
+ });
100
+
101
+ it("plans without mutating anything", async () => {
102
+ const store = createStore({ indexes: [{ key: { completed: 1 } }] });
103
+
104
+ const plan = await store.planIndexes();
105
+
106
+ assert.deepEqual(calls, ["listIndexes"]);
107
+ assert.deepEqual(
108
+ plan.toCreate.map(({ name }) => name),
109
+ ["completed_1"],
110
+ );
111
+ });
112
+
113
+ it("drops, then collMods, then creates", async () => {
114
+ existingIndexes = [
115
+ idIndex,
116
+ { v: 2, key: { status: 1 }, name: "legacy_status_1" },
117
+ { v: 2, key: { label: 1 }, name: "label_1", expireAfterSeconds: 3600 },
118
+ ];
119
+
120
+ const store = createStore({
121
+ indexes: [
122
+ { key: { completed: 1 } },
123
+ { key: { label: 1 }, expireAfterSeconds: 60 },
124
+ ],
125
+ });
126
+
127
+ await store.syncIndexes();
128
+
129
+ assert.deepEqual(calls, [
130
+ "listIndexes",
131
+ "dropIndex:legacy_status_1",
132
+ 'command:{"collMod":"tasks","index":{"name":"label_1","expireAfterSeconds":60}}',
133
+ "createIndexes:completed_1",
134
+ ]);
135
+ });
136
+
137
+ it("never drops the _id index", async () => {
138
+ existingIndexes = [idIndex];
139
+ await createStore({ indexes: [] }).syncIndexes();
140
+
141
+ assert.equal(
142
+ calls.some((call) => call.startsWith("dropIndex")),
143
+ false,
144
+ );
145
+ });
146
+
147
+ it("mutates nothing on a dry run but still returns the plan", async () => {
148
+ existingIndexes = [
149
+ idIndex,
150
+ { v: 2, key: { status: 1 }, name: "legacy_status_1" },
151
+ ];
152
+
153
+ const result = await createStore({
154
+ indexes: [{ key: { completed: 1 } }],
155
+ }).syncIndexes({ dryRun: true });
156
+
157
+ assert.deepEqual(calls, ["listIndexes"]);
158
+ assert.equal(result.dryRun, true);
159
+ assert.deepEqual(
160
+ result.plan.toDrop.map(({ name }) => name),
161
+ ["legacy_status_1"],
162
+ );
163
+ assert.deepEqual(result.created, []);
164
+ });
165
+
166
+ it("keeps undeclared indexes when dropUndeclaredIndexes is false", async () => {
167
+ existingIndexes = [
168
+ idIndex,
169
+ { v: 2, key: { status: 1 }, name: "legacy_status_1" },
170
+ ];
171
+
172
+ await createStore({ indexes: [] }).syncIndexes({
173
+ dropUndeclaredIndexes: false,
174
+ });
175
+
176
+ assert.deepEqual(calls, ["listIndexes"]);
177
+ });
178
+
179
+ it("ignores an index already dropped by another process", async () => {
180
+ existingIndexes = [
181
+ idIndex,
182
+ { v: 2, key: { status: 1 }, name: "legacy_status_1" },
183
+ ];
184
+ const dropIndex = mock.method(collection, "dropIndex", () =>
185
+ Promise.reject(Object.assign(new Error("not found"), { code: 27 })),
186
+ );
187
+
188
+ const result = await createStore({ indexes: [] }).syncIndexes();
189
+
190
+ assert.equal(dropIndex.mock.callCount(), 1);
191
+ assert.deepEqual(result.dropped, []);
192
+ dropIndex.mock.restore();
193
+ });
194
+
195
+ it("recovers once from a conflicting index spec", async () => {
196
+ existingIndexes = [idIndex];
197
+ let createAttempts = 0;
198
+ createIndexesImplementation = () => {
199
+ createAttempts += 1;
200
+ if (createAttempts <= 2) {
201
+ existingIndexes = [
202
+ idIndex,
203
+ { v: 2, key: { completed: -1 }, name: "completed_1" },
204
+ ];
205
+ return Promise.reject(
206
+ Object.assign(new Error("conflict"), { code: 85 }),
207
+ );
208
+ }
209
+ return Promise.resolve(["ok"]);
210
+ };
211
+
212
+ const result = await createStore({
213
+ indexes: [{ key: { completed: 1 } }],
214
+ }).syncIndexes();
215
+
216
+ assert.equal(createAttempts, 3);
217
+ assert.deepEqual(calls.slice(1), [
218
+ "createIndexes:completed_1",
219
+ "createIndexes:completed_1",
220
+ "listIndexes",
221
+ "dropIndex:completed_1",
222
+ "createIndexes:completed_1",
223
+ ]);
224
+ assert.deepEqual(result.created, ["completed_1"]);
225
+ });
226
+ });
227
+
228
+ describe("MongoRegistry with mongo stores", () => {
229
+ beforeEach(() => {
230
+ calls = [];
231
+ existingIndexes = [idIndex];
232
+ createIndexesImplementation = () => Promise.resolve(["ok"]);
233
+ });
234
+
235
+ it("syncs every registered store", async () => {
236
+ const connection = new MongoConnection({ database: "test" });
237
+ const registry = new MongoRegistry([
238
+ new MongoStore<TestModel>(connection, "users", {
239
+ indexes: [{ key: { label: 1 } }],
240
+ }),
241
+ new MongoStore<TestModel>(connection, "tasks", {
242
+ indexes: [{ key: { completed: 1 } }],
243
+ }),
244
+ ]);
245
+
246
+ const results = await registry.syncIndexes();
247
+
248
+ assert.deepEqual(
249
+ results.map(({ collectionName }) => collectionName),
250
+ ["tasks", "users"],
251
+ );
252
+ assert.deepEqual(calls, [
253
+ "listIndexes",
254
+ "createIndexes:completed_1",
255
+ "listIndexes",
256
+ "createIndexes:label_1",
257
+ ]);
258
+ });
259
+ });
package/src/index.ts CHANGED
@@ -6,7 +6,7 @@ export type {
6
6
 
7
7
  export type { Update } from "liwi-store";
8
8
 
9
- export { default as MongoStore } from "./MongoStore.ts";
9
+ export { default as MongoStore, type MongoStoreOptions } from "./MongoStore.ts";
10
10
  export {
11
11
  default as MongoConnection,
12
12
  type MongoConfig,
@@ -15,3 +15,28 @@ export {
15
15
  default as createMongoSubscribeStore,
16
16
  type SubscribeStore,
17
17
  } from "./createMongoSubscribeStore.ts";
18
+ export {
19
+ default as MongoRegistry,
20
+ type MongoRegistryOptions,
21
+ } from "./MongoRegistry.ts";
22
+ export type { MongoRegistryStore } from "./MongoRegistryStore.ts";
23
+
24
+ export type {
25
+ MongoIndex,
26
+ MongoIndexDirection,
27
+ MongoIndexKey,
28
+ MongoIndexOptions,
29
+ MongoIndexPlan,
30
+ MongoIndexSyncResult,
31
+ SyncIndexesOptions,
32
+ } from "./indexes/types.ts";
33
+ export { buildIndexName } from "./indexes/buildIndexName.ts";
34
+ export { diffIndexes, isMongoIndexPlanEmpty } from "./indexes/diffIndexes.ts";
35
+ export {
36
+ formatIndexPlan,
37
+ formatIndexPlans,
38
+ } from "./indexes/formatIndexPlan.ts";
39
+ export {
40
+ runIndexesCli,
41
+ type RunIndexesCliParams,
42
+ } from "./indexes/runIndexesCli.ts";
@@ -0,0 +1,149 @@
1
+ import type {
2
+ Collection,
3
+ Db,
4
+ IndexDescription,
5
+ IndexDescriptionInfo,
6
+ } from "mongodb";
7
+ import { Logger } from "nightingale-logger";
8
+ import {
9
+ isIndexConflictError,
10
+ isIndexNotFoundError,
11
+ } from "./isIndexConflictError.ts";
12
+ import type { MongoIndexPlan, MongoIndexSyncResult } from "./types.ts";
13
+
14
+ const logger = new Logger("liwi:mongo:indexes");
15
+
16
+ const idIndexName = "_id_";
17
+
18
+ const serializeKey = (key: IndexDescription["key"]): string =>
19
+ JSON.stringify(key instanceof Map ? [...key.entries()] : Object.entries(key));
20
+
21
+ const findConflictingIndex = (
22
+ existingIndexes: readonly IndexDescriptionInfo[],
23
+ description: IndexDescription,
24
+ ): IndexDescriptionInfo | undefined => {
25
+ const byName = existingIndexes.find(({ name }) => name === description.name);
26
+ if (byName) return byName;
27
+
28
+ const serializedKey = serializeKey(description.key);
29
+ return existingIndexes.find(
30
+ ({ key, name }) =>
31
+ name !== idIndexName && serializeKey(key) === serializedKey,
32
+ );
33
+ };
34
+
35
+ interface CreateIndexParams {
36
+ collection: Collection<any>;
37
+ collectionName: string;
38
+ description: IndexDescription;
39
+ }
40
+
41
+ const createIndexWithConflictRecovery = async ({
42
+ collection,
43
+ collectionName,
44
+ description,
45
+ }: CreateIndexParams): Promise<void> => {
46
+ try {
47
+ await collection.createIndexes([description]);
48
+ return;
49
+ } catch (error: unknown) {
50
+ if (!isIndexConflictError(error)) throw error;
51
+
52
+ logger.warn("index conflict, recovering", {
53
+ collectionName,
54
+ name: description.name,
55
+ error,
56
+ });
57
+
58
+ const existingIndexes = await collection.listIndexes().toArray();
59
+ const conflicting = findConflictingIndex(existingIndexes, description);
60
+ if (!conflicting?.name) throw error;
61
+
62
+ await collection.dropIndex(conflicting.name);
63
+ }
64
+
65
+ await collection.createIndexes([description]);
66
+ };
67
+
68
+ interface ApplyIndexPlanParams {
69
+ plan: MongoIndexPlan;
70
+ collection: Collection<any>;
71
+ db: Db;
72
+ dryRun: boolean;
73
+ }
74
+
75
+ export const applyIndexPlan = async ({
76
+ plan,
77
+ collection,
78
+ db,
79
+ dryRun,
80
+ }: ApplyIndexPlanParams): Promise<MongoIndexSyncResult> => {
81
+ const { collectionName } = plan;
82
+ const result: MongoIndexSyncResult = {
83
+ collectionName,
84
+ plan,
85
+ dryRun,
86
+ created: [],
87
+ dropped: [],
88
+ modified: [],
89
+ };
90
+
91
+ if (dryRun) return result;
92
+
93
+ const namesToDrop = [...plan.toDrop, ...plan.toRecreate]
94
+ .map(({ name }) => name)
95
+ .filter((name) => name !== idIndexName);
96
+
97
+ for (const name of namesToDrop) {
98
+ try {
99
+ await collection.dropIndex(name);
100
+ logger.info("dropIndex", { collectionName, name });
101
+ result.dropped.push(name);
102
+ } catch (error: unknown) {
103
+ if (!isIndexNotFoundError(error)) throw error;
104
+ logger.warn("index already dropped", { collectionName, name });
105
+ }
106
+ }
107
+
108
+ for (const { name, changes } of plan.toCollMod) {
109
+ await db.command({
110
+ collMod: collectionName,
111
+ index: { name, ...changes },
112
+ });
113
+ logger.info("collMod", { collectionName, name, changes });
114
+ result.modified.push(name);
115
+ }
116
+
117
+ const descriptions = [...plan.toCreate, ...plan.toRecreate].map(
118
+ ({ index }) => index,
119
+ );
120
+
121
+ if (descriptions.length > 0) {
122
+ try {
123
+ await collection.createIndexes(descriptions);
124
+ } catch (error: unknown) {
125
+ logger.warn("createIndexes failed, retrying one by one", {
126
+ collectionName,
127
+ error,
128
+ });
129
+ for (const description of descriptions) {
130
+ await createIndexWithConflictRecovery({
131
+ collection,
132
+ collectionName,
133
+ description,
134
+ });
135
+ }
136
+ }
137
+ result.created.push(...descriptions.map(({ name }) => name!));
138
+ logger.info("createIndexes", { collectionName, names: result.created });
139
+ }
140
+
141
+ logger.info("syncIndexes done", {
142
+ collectionName,
143
+ created: result.created,
144
+ dropped: result.dropped,
145
+ modified: result.modified,
146
+ });
147
+
148
+ return result;
149
+ };
@@ -0,0 +1,34 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { buildIndexName } from "./buildIndexName.ts";
4
+
5
+ describe("buildIndexName", () => {
6
+ it("names a single ascending field like mongo does", () => {
7
+ assert.equal(buildIndexName({ completed: 1 }), "completed_1");
8
+ });
9
+
10
+ it("names a compound index", () => {
11
+ assert.equal(
12
+ buildIndexName({ completed: 1, created: 1 }),
13
+ "completed_1_created_1",
14
+ );
15
+ });
16
+
17
+ it("keeps descending directions", () => {
18
+ assert.equal(buildIndexName({ created: -1 }), "created_-1");
19
+ });
20
+
21
+ it("names dotted paths", () => {
22
+ assert.equal(buildIndexName({ "author.name": 1 }), "author.name_1");
23
+ });
24
+
25
+ it("names non numeric directions", () => {
26
+ assert.equal(buildIndexName({ label: "text" }), "label_text");
27
+ assert.equal(buildIndexName({ location: "2dsphere" }), "location_2dsphere");
28
+ assert.equal(buildIndexName({ tenant: "hashed" }), "tenant_hashed");
29
+ });
30
+
31
+ it("names wildcard indexes", () => {
32
+ assert.equal(buildIndexName({ "$**": 1 }), "$**_1");
33
+ });
34
+ });
@@ -0,0 +1,8 @@
1
+ import type { MongoIndexDirection } from "./types.ts";
2
+
3
+ export const buildIndexName = (
4
+ key: Record<string, MongoIndexDirection>,
5
+ ): string =>
6
+ Object.entries(key)
7
+ .map(([field, direction]) => `${field}_${direction}`)
8
+ .join("_");
@@ -0,0 +1,36 @@
1
+ const isPlainRecord = (value: unknown): value is Record<string, unknown> =>
2
+ typeof value === "object" &&
3
+ value !== null &&
4
+ !Array.isArray(value) &&
5
+ !(value instanceof Date) &&
6
+ !(value instanceof RegExp);
7
+
8
+ export const deepEqualUnordered = (a: unknown, b: unknown): boolean => {
9
+ if (a === b) return true;
10
+
11
+ if (a instanceof Date && b instanceof Date) {
12
+ return a.getTime() === b.getTime();
13
+ }
14
+
15
+ if (a instanceof RegExp && b instanceof RegExp) {
16
+ return a.source === b.source && a.flags === b.flags;
17
+ }
18
+
19
+ if (Array.isArray(a) || Array.isArray(b)) {
20
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) {
21
+ return false;
22
+ }
23
+ return a.every((item, index) => deepEqualUnordered(item, b[index]));
24
+ }
25
+
26
+ if (!isPlainRecord(a) || !isPlainRecord(b)) return false;
27
+
28
+ const keysA = Object.keys(a);
29
+ const keysB = Object.keys(b);
30
+ if (keysA.length !== keysB.length) return false;
31
+
32
+ return keysA.every(
33
+ (key) =>
34
+ Object.hasOwn(b, key) && deepEqualUnordered(a[key], (b as any)[key]),
35
+ );
36
+ };