liwi-mongo 13.3.3 → 14.0.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 (40) hide show
  1. package/README.md +27 -2
  2. package/dist/definitions/MongoConnection.d.ts +7 -1
  3. package/dist/definitions/MongoConnection.d.ts.map +1 -1
  4. package/dist/definitions/MongoConnection.test.d.ts +2 -0
  5. package/dist/definitions/MongoConnection.test.d.ts.map +1 -0
  6. package/dist/definitions/MongoCursor.d.ts.map +1 -1
  7. package/dist/definitions/MongoQuerySingleItem.d.ts +1 -0
  8. package/dist/definitions/MongoQuerySingleItem.d.ts.map +1 -1
  9. package/dist/definitions/MongoQuerySingleItem.test.d.ts +2 -0
  10. package/dist/definitions/MongoQuerySingleItem.test.d.ts.map +1 -0
  11. package/dist/definitions/MongoStore.d.ts +18 -4
  12. package/dist/definitions/MongoStore.d.ts.map +1 -1
  13. package/dist/definitions/SubscribeStore.integration.test.d.ts +2 -0
  14. package/dist/definitions/SubscribeStore.integration.test.d.ts.map +1 -0
  15. package/dist/definitions/writes/createReplacePipeline.d.ts +10 -0
  16. package/dist/definitions/writes/createReplacePipeline.d.ts.map +1 -0
  17. package/dist/definitions/writes/stampPartialUpdate.d.ts +9 -0
  18. package/dist/definitions/writes/stampPartialUpdate.d.ts.map +1 -0
  19. package/dist/definitions/writes/stampPartialUpdate.test.d.ts +2 -0
  20. package/dist/definitions/writes/stampPartialUpdate.test.d.ts.map +1 -0
  21. package/dist/index-node.mjs +225 -93
  22. package/dist/index-node.mjs.map +1 -1
  23. package/package.json +9 -8
  24. package/src/MongoConnection.test.ts +75 -0
  25. package/src/MongoConnection.ts +38 -25
  26. package/src/MongoCursor.ts +2 -4
  27. package/src/MongoQueryCollection.test.ts +9 -23
  28. package/src/MongoQueryCollection.ts +2 -2
  29. package/src/MongoQuerySingleItem.test.ts +162 -0
  30. package/src/MongoQuerySingleItem.ts +58 -52
  31. package/src/MongoStore.test.ts +259 -18
  32. package/src/MongoStore.ts +122 -26
  33. package/src/MongoStoreIndexes.test.ts +10 -24
  34. package/src/SubscribeStore.integration.test.ts +414 -0
  35. package/src/indexes/diffIndexes.test.ts +2 -2
  36. package/src/indexes/normalizeIndex.test.ts +2 -2
  37. package/src/indexes/normalizeIndex.ts +2 -2
  38. package/src/writes/createReplacePipeline.ts +23 -0
  39. package/src/writes/stampPartialUpdate.test.ts +93 -0
  40. package/src/writes/stampPartialUpdate.ts +84 -0
@@ -10,8 +10,36 @@ export interface MongoConfig {
10
10
  database: string;
11
11
  user?: string;
12
12
  password?: string;
13
+ authSource?: string;
13
14
  }
14
15
 
16
+ interface BuildMongoConnectionStringParams extends MongoConfig {
17
+ redactCredentials: boolean;
18
+ }
19
+
20
+ export const buildMongoConnectionString = ({
21
+ host = "localhost",
22
+ port = "27017",
23
+ database,
24
+ user,
25
+ password,
26
+ authSource,
27
+ redactCredentials,
28
+ }: BuildMongoConnectionStringParams): string =>
29
+ `mongodb://${
30
+ user
31
+ ? `${
32
+ redactCredentials
33
+ ? `${user.slice(0, 2)}[redacted]`
34
+ : encodeURIComponent(user)
35
+ }:${
36
+ redactCredentials ? "[redacted]" : encodeURIComponent(password ?? "")
37
+ }@`
38
+ : ""
39
+ }${host}:${port}/${encodeURIComponent(database)}${
40
+ authSource ? `?authSource=${encodeURIComponent(authSource)}` : ""
41
+ }`;
42
+
15
43
  export default class MongoConnection extends AbstractConnection {
16
44
  _connection?: mongodb.MongoClient;
17
45
 
@@ -20,36 +48,21 @@ export default class MongoConnection extends AbstractConnection {
20
48
  connectionFailed?: boolean;
21
49
 
22
50
  // TODO interface
23
- constructor({
24
- host = "localhost",
25
- port = "27017",
26
- database,
27
- user,
28
- password,
29
- }: MongoConfig) {
51
+ constructor(config: MongoConfig) {
30
52
  super();
31
53
 
32
- if (!database) {
54
+ if (!config.database) {
33
55
  throw new Error("Missing config database");
34
56
  }
35
57
 
36
- const buildConnectionString = (redactCredentials: boolean): string =>
37
- `mongodb://${
38
- user
39
- ? `${
40
- redactCredentials
41
- ? `${user.slice(0, 2)}[redacted]`
42
- : encodeURIComponent(user)
43
- }:${
44
- redactCredentials
45
- ? "[redacted]"
46
- : encodeURIComponent(password ?? "")
47
- }@`
48
- : ""
49
- }${host}:${port}/${encodeURIComponent(database)}`;
50
-
51
- const connectionString = buildConnectionString(false);
52
- const connectionStringRedacted = buildConnectionString(true);
58
+ const connectionString = buildMongoConnectionString({
59
+ ...config,
60
+ redactCredentials: false,
61
+ });
62
+ const connectionStringRedacted = buildMongoConnectionString({
63
+ ...config,
64
+ redactCredentials: true,
65
+ });
53
66
 
54
67
  this.connect(connectionString, connectionStringRedacted);
55
68
  }
@@ -55,12 +55,10 @@ export default class MongoCursor<
55
55
  return Promise.resolve(this._result);
56
56
  }
57
57
 
58
- close(): Promise<void> {
58
+ async close(): Promise<void> {
59
59
  if (this.cursor) {
60
- this.cursor.close();
60
+ await this.cursor.close();
61
61
  }
62
-
63
- return Promise.resolve();
64
62
  }
65
63
 
66
64
  toArray(): Promise<Result[]> {
@@ -1,6 +1,9 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { beforeEach, describe, it, mock } from "node:test";
3
+ import mongodb from "mongodb";
3
4
  import type { MongoBaseModel } from "./MongoBaseModel.ts";
5
+ import MongoConnection from "./MongoConnection.ts";
6
+ import MongoStore from "./MongoStore.ts";
4
7
 
5
8
  interface TestModel extends MongoBaseModel {
6
9
  name: string;
@@ -50,29 +53,12 @@ const collection = {
50
53
  countDocuments: () => Promise.resolve(documents.length),
51
54
  };
52
55
 
53
- // eslint-disable-next-line n/no-unsupported-features/node-builtins
54
- mock.module("mongodb", {
55
- // @ts-expect-error: not yet typed
56
- exports: {
57
- default: {
58
- MongoClient: {
59
- connect: () =>
60
- Promise.resolve({
61
- on: () => undefined,
62
- db: () => ({ collection: () => collection }),
63
- }),
64
- },
65
- ObjectId: class {
66
- toString(): string {
67
- return "generated-id";
68
- }
69
- },
70
- },
71
- },
72
- });
73
-
74
- const { default: MongoConnection } = await import("./MongoConnection.ts");
75
- const { default: MongoStore } = await import("./MongoStore.ts");
56
+ mock.method(mongodb.MongoClient, "connect", () =>
57
+ Promise.resolve({
58
+ on: () => undefined,
59
+ db: () => ({ collection: () => collection }),
60
+ } as unknown as mongodb.MongoClient),
61
+ );
76
62
 
77
63
  const createStore = (): InstanceType<typeof MongoStore<TestModel>> => {
78
64
  const connection = new MongoConnection({ database: "test" });
@@ -11,7 +11,7 @@ import type {
11
11
  } from "liwi-store";
12
12
  import type { Actions } from "liwi-subscribe-store";
13
13
  import { AbstractSubscribableStoreQuery } from "liwi-subscribe-store";
14
- import mingo from "mingo";
14
+ import { Query as MingoQuery } from "mingo";
15
15
  import type {
16
16
  MongoBaseModel,
17
17
  MongoInsertType,
@@ -72,7 +72,7 @@ export default class MongoQueryCollection<
72
72
  return () => false;
73
73
  }
74
74
 
75
- const mingoQuery = new mingo.Query(this.options.criteria);
75
+ const mingoQuery = new MingoQuery(this.options.criteria);
76
76
  this.testCriteria = mingoQuery.test.bind(mingoQuery);
77
77
  }
78
78
  return this.testCriteria;
@@ -0,0 +1,162 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { setImmediate } from "node:timers/promises";
4
+ import type { Changes } from "liwi-store";
5
+ import { SubscribeStore } from "liwi-subscribe-store";
6
+ import type { MongoBaseModel } from "./MongoBaseModel.ts";
7
+ import MongoQuerySingleItem from "./MongoQuerySingleItem.ts";
8
+ import type MongoStore from "./MongoStore.ts";
9
+
10
+ interface TestModel extends MongoBaseModel {
11
+ status: string;
12
+ }
13
+
14
+ const created = new Date("2020-01-01");
15
+ const updated = new Date("2021-01-01");
16
+
17
+ interface SubscribeSingleItemOptions {
18
+ cursor?: MongoStore<TestModel>["cursor"];
19
+ sort?: Record<string, -1 | 1>;
20
+ }
21
+
22
+ const subscribeSingleItem = ({
23
+ cursor = () => Promise.reject(new Error("unexpected fetch")),
24
+ sort,
25
+ }: SubscribeSingleItemOptions = {}): {
26
+ subscribeStore: SubscribeStore<any, any, TestModel, any, any, any>;
27
+ received: Changes<string, TestModel | null>[];
28
+ errors: Error[];
29
+ } => {
30
+ const store = { keyPath: "_id", cursor } as unknown as MongoStore<TestModel>;
31
+ const subscribeStore = new SubscribeStore<any, any, TestModel, any, any, any>(
32
+ store,
33
+ );
34
+ const query = new MongoQuerySingleItem<TestModel, never, TestModel | null>(
35
+ store,
36
+ { criteria: { status: "open" }, sort },
37
+ );
38
+ query.setSubscribeStore(subscribeStore);
39
+
40
+ const received: Changes<string, TestModel | null>[] = [];
41
+ const errors: Error[] = [];
42
+ query.subscribe((error, changes) => {
43
+ if (error) errors.push(error);
44
+ else received.push(changes);
45
+ });
46
+ return { subscribeStore, received, errors };
47
+ };
48
+
49
+ const createCursor = (
50
+ documents: TestModel[],
51
+ ): MongoStore<TestModel>["cursor"] =>
52
+ (() => {
53
+ const cursor = {
54
+ limit: () => Promise.resolve(cursor),
55
+ toArray: () => Promise.resolve(documents),
56
+ };
57
+ return Promise.resolve(cursor);
58
+ }) as unknown as MongoStore<TestModel>["cursor"];
59
+
60
+ const draft: TestModel = { _id: "1", status: "draft", created, updated };
61
+ const open: TestModel = { _id: "1", status: "open", created, updated };
62
+ const closed: TestModel = { _id: "1", status: "closed", created, updated };
63
+ const draft2: TestModel = { ...draft, _id: "2" };
64
+ const open2: TestModel = { ...open, _id: "2" };
65
+
66
+ describe("MongoQuerySingleItem subscription", () => {
67
+ it("pushes a document that enters the criteria through an update", async () => {
68
+ const { subscribeStore, received } = subscribeSingleItem();
69
+
70
+ subscribeStore.callSubscribed({
71
+ type: "updated",
72
+ changes: [[draft, open]],
73
+ });
74
+ await setImmediate();
75
+
76
+ assert.deepEqual(received, [[{ type: "updated", result: open }]]);
77
+ });
78
+
79
+ it("pushes null when the document leaves the criteria", async () => {
80
+ const { subscribeStore, received } = subscribeSingleItem();
81
+
82
+ subscribeStore.callSubscribed({
83
+ type: "updated",
84
+ changes: [[open, closed]],
85
+ });
86
+ await setImmediate();
87
+
88
+ assert.deepEqual(received, [[{ type: "updated", result: null }]]);
89
+ });
90
+
91
+ it("ignores an update outside the criteria before and after", async () => {
92
+ const { subscribeStore, received, errors } = subscribeSingleItem();
93
+
94
+ subscribeStore.callSubscribed({
95
+ type: "updated",
96
+ changes: [[draft, closed]],
97
+ });
98
+ await setImmediate();
99
+
100
+ assert.deepEqual(received, []);
101
+ assert.deepEqual(errors, []);
102
+ });
103
+
104
+ it("reports several matches without sort through the callback", async () => {
105
+ const { subscribeStore, received, errors } = subscribeSingleItem();
106
+
107
+ subscribeStore.callSubscribed({
108
+ type: "updated",
109
+ changes: [
110
+ [draft, open],
111
+ [draft2, open2],
112
+ ],
113
+ });
114
+ await setImmediate();
115
+
116
+ assert.deepEqual(received, []);
117
+ assert.deepEqual(
118
+ errors.map((error) => error.message),
119
+ ["should not match more than 1, use sort if you can have multiple match"],
120
+ );
121
+ });
122
+
123
+ it("refetches with a sort when several documents enter the criteria", async () => {
124
+ const { subscribeStore, received, errors } = subscribeSingleItem({
125
+ cursor: createCursor([open2]),
126
+ sort: { _id: -1 },
127
+ });
128
+
129
+ subscribeStore.callSubscribed({
130
+ type: "updated",
131
+ changes: [
132
+ [draft, open],
133
+ [draft2, open2],
134
+ ],
135
+ });
136
+ await setImmediate();
137
+
138
+ assert.deepEqual(errors, []);
139
+ assert.deepEqual(received, [[{ type: "updated", result: open2 }]]);
140
+ });
141
+
142
+ it("reports a failed refetch through the callback", async () => {
143
+ const { subscribeStore, received, errors } = subscribeSingleItem({
144
+ sort: { _id: -1 },
145
+ });
146
+
147
+ subscribeStore.callSubscribed({
148
+ type: "updated",
149
+ changes: [
150
+ [draft, open],
151
+ [draft2, open2],
152
+ ],
153
+ });
154
+ await setImmediate();
155
+
156
+ assert.deepEqual(received, []);
157
+ assert.deepEqual(
158
+ errors.map((error) => error.message),
159
+ ["unexpected fetch"],
160
+ );
161
+ });
162
+ });
@@ -10,7 +10,7 @@ import type {
10
10
  } from "liwi-store";
11
11
  import type { Actions } from "liwi-subscribe-store";
12
12
  import { AbstractSubscribableStoreQuery } from "liwi-subscribe-store";
13
- import mingo from "mingo";
13
+ import { Query as MingoQuery } from "mingo";
14
14
  import type {
15
15
  MongoBaseModel,
16
16
  MongoInsertType,
@@ -66,7 +66,7 @@ export default class MongoQuerySingleItem<
66
66
  return () => true;
67
67
  }
68
68
 
69
- const mingoQuery = new mingo.Query(this.options.criteria);
69
+ const mingoQuery = new MingoQuery(this.options.criteria);
70
70
  this.testCriteria = mingoQuery.test.bind(mingoQuery);
71
71
  }
72
72
 
@@ -90,6 +90,56 @@ export default class MongoQuerySingleItem<
90
90
  });
91
91
  }
92
92
 
93
+ private async computeChanges(
94
+ action: Actions<Model>,
95
+ testCriteria: TestCriteria,
96
+ ): Promise<Changes<KeyValue, Result>> {
97
+ switch (action.type) {
98
+ case "inserted": {
99
+ const filtered = action.next.filter(testCriteria);
100
+ if (filtered.length === 0) return [];
101
+ return [{ type: "updated", result: this.transformer(filtered[0]!) }];
102
+ }
103
+ case "deleted": {
104
+ const filtered = action.prev.filter(testCriteria);
105
+ if (filtered.length === 0) return [];
106
+ return [
107
+ {
108
+ type: "deleted",
109
+ keys: filtered.map((object) => object[this.store.keyPath]),
110
+ },
111
+ ];
112
+ }
113
+ case "updated": {
114
+ const filtered = action.changes.filter(
115
+ ([prev, next]) => testCriteria(prev) || testCriteria(next),
116
+ );
117
+ if (filtered.length === 0) return [];
118
+
119
+ if (this.options.sort) {
120
+ const { result } = await this.fetch((res) => res);
121
+ return [{ type: "updated", result }];
122
+ }
123
+
124
+ if (filtered.length > 1) {
125
+ throw new Error(
126
+ "should not match more than 1, use sort if you can have multiple match",
127
+ );
128
+ }
129
+
130
+ const [, next] = filtered[0]!;
131
+ return [
132
+ {
133
+ type: "updated",
134
+ result: testCriteria(next) ? this.transformer(next) : null!,
135
+ },
136
+ ];
137
+ }
138
+ default:
139
+ throw new Error("Unsupported type");
140
+ }
141
+ }
142
+
93
143
  _subscribe(
94
144
  callback: SubscribeCallback<KeyValue, Result>,
95
145
  _includeInitial: boolean,
@@ -111,56 +161,12 @@ export default class MongoQuerySingleItem<
111
161
  : Promise.resolve();
112
162
 
113
163
  const unsubscribe = store.subscribe(async (action: Actions<Model>) => {
114
- const changes: Changes<KeyValue, Result> = [];
115
- switch (action.type) {
116
- case "inserted": {
117
- const filtered = action.next.filter(testCriteria);
118
- if (filtered.length > 0) {
119
- changes.push({
120
- type: "updated",
121
- result: this.transformer(filtered[0]!),
122
- });
123
- }
124
- break;
125
- }
126
- case "deleted": {
127
- const filtered = action.prev.filter(testCriteria);
128
- if (filtered.length > 0) {
129
- changes.push({
130
- type: "deleted",
131
- keys: filtered.map((object) => object[this.store.keyPath]),
132
- });
133
- }
134
- break;
135
- }
136
- case "updated": {
137
- const filtered = action.changes.filter(([prev, next]) =>
138
- testCriteria(prev),
139
- );
140
- if (filtered.length > 0) {
141
- if (this.options.sort) {
142
- const { result } = await this.fetch((res) => res);
143
- changes.push({
144
- type: "updated",
145
- result,
146
- });
147
- } else if (filtered.length !== 1) {
148
- throw new Error(
149
- "should not match more than 1, use sort if you can have multiple match",
150
- );
151
- } else {
152
- const [, next] = filtered[0]!;
153
- changes.push({
154
- type: "updated",
155
- result: testCriteria(next) ? this.transformer(next) : null!,
156
- });
157
- }
158
- } else if (filtered.length === 0) {
159
- }
160
- break;
161
- }
162
- default:
163
- throw new Error("Unsupported type");
164
+ let changes: Changes<KeyValue, Result>;
165
+ try {
166
+ changes = await this.computeChanges(action, testCriteria);
167
+ } catch (error: unknown) {
168
+ callback(error as Error, []);
169
+ return;
164
170
  }
165
171
 
166
172
  if (changes.length === 0) return;