@tsed/adapters-redis 8.0.1 → 8.0.2

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/package.json CHANGED
@@ -2,13 +2,14 @@
2
2
  "name": "@tsed/adapters-redis",
3
3
  "description": "Redis adapter for Ts.ED",
4
4
  "type": "module",
5
- "version": "8.0.1",
5
+ "version": "8.0.2",
6
6
  "source": "./src/index.ts",
7
7
  "main": "./lib/esm/index.js",
8
8
  "module": "./lib/esm/index.js",
9
9
  "typings": "./lib/types/index.d.ts",
10
10
  "exports": {
11
11
  ".": {
12
+ "@tsed/source": "./src/index.ts",
12
13
  "types": "./lib/types/index.d.ts",
13
14
  "import": "./lib/esm/index.js",
14
15
  "default": "./lib/esm/index.js"
@@ -22,25 +23,25 @@
22
23
  "test:ci": "vitest run --coverage.thresholds.autoUpdate=true"
23
24
  },
24
25
  "dependencies": {
25
- "@tsed/adapters": "8.0.1",
26
- "@tsed/ioredis": "8.0.1",
26
+ "@tsed/adapters": "8.0.2",
27
+ "@tsed/ioredis": "8.0.2",
27
28
  "tslib": "2.7.0"
28
29
  },
29
30
  "devDependencies": {
30
- "@tsed/barrels": "8.0.1",
31
- "@tsed/core": "8.0.1",
32
- "@tsed/hooks": "8.0.1",
33
- "@tsed/typescript": "8.0.1",
31
+ "@tsed/barrels": "8.0.2",
32
+ "@tsed/core": "8.0.2",
33
+ "@tsed/hooks": "8.0.2",
34
+ "@tsed/typescript": "8.0.2",
34
35
  "eslint": "9.12.0",
35
36
  "typescript": "5.4.5",
36
37
  "vitest": "2.1.2"
37
38
  },
38
39
  "peerDependencies": {
39
- "@tsed/adapters": "8.0.1",
40
- "@tsed/core": "8.0.1",
41
- "@tsed/di": "8.0.1",
42
- "@tsed/hooks": "8.0.1",
43
- "@tsed/platform-http": "8.0.1",
40
+ "@tsed/adapters": "8.0.2",
41
+ "@tsed/core": "8.0.2",
42
+ "@tsed/di": "8.0.2",
43
+ "@tsed/hooks": "8.0.2",
44
+ "@tsed/platform-http": "8.0.2",
44
45
  "ioredis": ">=5.2.3",
45
46
  "ioredis-mock": ">=8.2.2",
46
47
  "uuid": "^10.0.0"
@@ -0,0 +1,184 @@
1
+ import {Adapters} from "@tsed/adapters";
2
+ import {IORedisTest, registerConnectionProvider} from "@tsed/ioredis";
3
+ import {Redis} from "ioredis";
4
+ // @ts-ignore
5
+ import IORedisMock from "ioredis-mock";
6
+ import moment from "moment";
7
+
8
+ import {OIDCRedisAdapter} from "./OIDCRedisAdapter.js";
9
+
10
+ const REDIS_CONNECTION = Symbol.for("redis_connection");
11
+
12
+ registerConnectionProvider({
13
+ token: REDIS_CONNECTION
14
+ });
15
+ function createAdapterFixture(collectionName: string) {
16
+ const adapter = IORedisTest.get<Adapters>(Adapters).invokeAdapter({
17
+ collectionName,
18
+ model: Object,
19
+ adapter: OIDCRedisAdapter
20
+ }) as OIDCRedisAdapter<any>;
21
+
22
+ const redis = IORedisTest.get<Redis>(REDIS_CONNECTION);
23
+
24
+ return {adapter, redis};
25
+ }
26
+
27
+ async function createInitialDBFixture() {
28
+ const {adapter} = await createAdapterFixture("AccessToken");
29
+
30
+ const payload = {
31
+ grantId: "grantId",
32
+ userCode: "userCode",
33
+ uid: "uid",
34
+ _id: "id"
35
+ };
36
+
37
+ await adapter.upsert("id", payload);
38
+
39
+ return adapter;
40
+ }
41
+
42
+ describe("OIDCRedisAdapter", () => {
43
+ beforeEach(() => IORedisTest.create());
44
+ afterEach(() => IORedisTest.reset());
45
+
46
+ describe("onInsert()", () => {
47
+ it("should test if the table is grantable and add index for grantId and the current payload", async () => {
48
+ const {adapter, redis} = await createAdapterFixture("AccessToken");
49
+
50
+ const payload = {
51
+ grantId: "grantId",
52
+ userCode: "userCode",
53
+ uid: "uid",
54
+ _id: "id"
55
+ };
56
+
57
+ await adapter.upsert("id", payload);
58
+
59
+ const keys = await redis.keys("$oidc:*");
60
+
61
+ expect(await redis.get("$oidc:grant:grantId")).toEqual(["AccessToken:id"]);
62
+ expect(keys).toEqual(["$oidc:grant:grantId", "$oidc:userCode:userCode", "$oidc:uid:uid"]);
63
+ });
64
+ it("should set the expiration ttl", async () => {
65
+ const {adapter, redis} = await createAdapterFixture("AccessToken");
66
+
67
+ const payload = {
68
+ grantId: "grantId",
69
+ userCode: "userCode",
70
+ uid: "uid",
71
+ _id: "id"
72
+ };
73
+
74
+ await adapter.upsert("id", payload, moment().add(2, "days").toDate());
75
+
76
+ const ttl = await redis.ttl("$oidc:grant:grantId");
77
+
78
+ expect(ttl).toBeGreaterThan(100000);
79
+ });
80
+ it("should create grant indexes if the payload type isn't grantable", async () => {
81
+ const {adapter, redis} = await createAdapterFixture("Other");
82
+
83
+ const payload = {
84
+ grantId: "grantId",
85
+ userCode: "userCode",
86
+ uid: "uid",
87
+ _id: "id"
88
+ };
89
+
90
+ await adapter.upsert("id", payload);
91
+
92
+ const keys = await redis.keys("$oidc:*");
93
+
94
+ expect(keys).toEqual(["$oidc:userCode:userCode", "$oidc:uid:uid"]);
95
+ });
96
+ });
97
+
98
+ describe("findByUid()", () => {
99
+ it("should retrieve the payload by his uid", async () => {
100
+ const adapter = await createInitialDBFixture();
101
+
102
+ const result = await adapter.findByUid("uid");
103
+
104
+ expect(result).toEqual({
105
+ _id: "id",
106
+ grantId: "grantId",
107
+ uid: "uid",
108
+ userCode: "userCode"
109
+ });
110
+ });
111
+ it("should not retrieve the payload by his uid", async () => {
112
+ const adapter = await createInitialDBFixture();
113
+
114
+ const result = await adapter.findByUid("wrong");
115
+
116
+ expect(result).toEqual(null);
117
+ });
118
+ });
119
+
120
+ describe("findByUserCode()", () => {
121
+ it("should retrieve the payload by his userCode", async () => {
122
+ const adapter = await createInitialDBFixture();
123
+
124
+ const result = await adapter.findByUserCode("userCode");
125
+
126
+ expect(result).toEqual({
127
+ _id: "id",
128
+ grantId: "grantId",
129
+ uid: "uid",
130
+ userCode: "userCode"
131
+ });
132
+ });
133
+
134
+ it("should not retrieve the payload by his userCode", async () => {
135
+ const adapter = await createInitialDBFixture();
136
+
137
+ const result = await adapter.findByUserCode("wrong");
138
+
139
+ expect(result).toEqual(null);
140
+ });
141
+ });
142
+
143
+ describe("destroy()", () => {
144
+ it("should retrieve the payload by his userCode", async () => {
145
+ const adapter = await createInitialDBFixture();
146
+
147
+ await adapter.destroy("id");
148
+
149
+ const result = await adapter.findById("id");
150
+
151
+ expect(result).toEqual(undefined);
152
+ });
153
+ });
154
+
155
+ describe("revokeGrantId()", () => {
156
+ it("should retrieve the payload by his userCode", async () => {
157
+ const adapter = await createInitialDBFixture();
158
+
159
+ const keys = await adapter.db.lrange("$oidc:grant:grantId", 0, -1);
160
+
161
+ expect(keys).toEqual(["AccessToken:id"]);
162
+
163
+ expect(await adapter.db.get("AccessToken:id")).toEqual('{"grantId":"grantId","userCode":"userCode","uid":"uid","_id":"id"}');
164
+ expect(await adapter.db.get("$oidc:grant:grantId")).toEqual(["AccessToken:id"]);
165
+
166
+ await adapter.revokeByGrantId("grantId");
167
+
168
+ expect(await adapter.db.get("AccessToken:id")).toEqual(null);
169
+ expect(await adapter.db.get("$oidc:grant:grantId")).toEqual(null);
170
+ });
171
+ });
172
+
173
+ describe("consumes()", () => {
174
+ it("should set a consume flag in redis", async () => {
175
+ const {adapter} = await createAdapterFixture("AuthorizationCode");
176
+
177
+ await adapter.consume("codeId");
178
+
179
+ const result = await adapter.db.hget("AuthorizationCode:codeId", "consumed");
180
+
181
+ expect(!isNaN(Number(result))).toBe(true);
182
+ });
183
+ });
184
+ });
@@ -0,0 +1,103 @@
1
+ import {AdapterModel} from "@tsed/adapters";
2
+ import {Configuration, Inject, Opts} from "@tsed/di";
3
+ import {IORedis, IOREDIS_CONNECTIONS} from "@tsed/ioredis";
4
+ import {ChainableCommander} from "ioredis";
5
+
6
+ import {RedisAdapter, RedisAdapterConstructorOptions} from "./RedisAdapter.js";
7
+
8
+ const GRANTABLE = new Set(["AccessToken", "AuthorizationCode", "RefreshToken", "DeviceCode", "BackchannelAuthenticationRequest"]);
9
+ const CONSUMABLE = new Set(["AuthorizationCode", "RefreshToken", "DeviceCode", "BackchannelAuthenticationRequest"]);
10
+
11
+ function grantKeyFor(id: string) {
12
+ return `$oidc:grant:${id}`;
13
+ }
14
+
15
+ function userCodeKeyFor(userCode: string) {
16
+ return `$oidc:userCode:${userCode}`;
17
+ }
18
+
19
+ function uidKeyFor(uid: string) {
20
+ return `$oidc:uid:${uid}`;
21
+ }
22
+
23
+ export class OIDCRedisAdapter<T extends AdapterModel> extends RedisAdapter<T> {
24
+ protected isGrantable: boolean;
25
+
26
+ constructor(
27
+ @Opts options: RedisAdapterConstructorOptions,
28
+ @Inject(IOREDIS_CONNECTIONS) connections: IORedis[],
29
+ @Configuration() protected configuration: Configuration
30
+ ) {
31
+ super(options, connections, configuration);
32
+
33
+ this.useHash = CONSUMABLE.has(this.collectionName);
34
+ this.isGrantable = GRANTABLE.has(this.collectionName);
35
+ this.hooks.on("insert", this.onInsert.bind(this));
36
+ }
37
+
38
+ async onInsert(multi: ChainableCommander, payload: T, expiresIn: number) {
39
+ const id = payload._id;
40
+
41
+ const key = this.key(id);
42
+
43
+ if (this.isGrantable && payload.grantId) {
44
+ const grantKey = grantKeyFor(payload.grantId);
45
+
46
+ multi.rpush(grantKey, key);
47
+ // if you're seeing grant key lists growing out of acceptable proportions consider using LTRIM
48
+ // here to trim the list to an appropriate length
49
+ const ttl = await this.db.ttl(grantKey);
50
+
51
+ if (expiresIn && expiresIn > ttl) {
52
+ multi.expire(grantKey, expiresIn);
53
+ }
54
+ }
55
+
56
+ if (payload.userCode) {
57
+ const userCodeKey = userCodeKeyFor(payload.userCode);
58
+
59
+ multi.set(userCodeKey, id);
60
+ expiresIn && multi.expire(userCodeKey, expiresIn);
61
+ }
62
+
63
+ if (payload.uid) {
64
+ const uidKey = uidKeyFor(payload.uid);
65
+
66
+ multi.set(uidKey, id);
67
+ expiresIn && multi.expire(uidKey, expiresIn);
68
+ }
69
+
70
+ return multi;
71
+ }
72
+
73
+ async findByUid(uid: string) {
74
+ const id = await this.db.get(uidKeyFor(uid));
75
+
76
+ return id && this.findById(id);
77
+ }
78
+
79
+ async findByUserCode(userCode: string) {
80
+ const id = await this.db.get(userCodeKeyFor(userCode));
81
+ return id && this.findById(id);
82
+ }
83
+
84
+ async destroy(id: string) {
85
+ const key = this.key(id);
86
+ await this.db.del(key);
87
+ }
88
+
89
+ async revokeByGrantId(grantId: string) {
90
+ const multi = this.db.multi();
91
+ const key = grantKeyFor(grantId);
92
+ const tokens = await this.db.lrange(key, 0, -1);
93
+
94
+ tokens.forEach((token) => multi.del(token));
95
+ multi.del(grantKeyFor(grantId));
96
+
97
+ await multi.exec();
98
+ }
99
+
100
+ async consume(id: string) {
101
+ await this.db.hset(this.key(id), "consumed", Math.floor(Date.now() / 1000));
102
+ }
103
+ }
@@ -0,0 +1,413 @@
1
+ import {AdapterModel, Adapters, Indexed} from "@tsed/adapters";
2
+ import {IORedisTest, registerConnectionProvider} from "@tsed/ioredis";
3
+ import {deserialize} from "@tsed/json-mapper";
4
+ import {Property, Required} from "@tsed/schema";
5
+
6
+ import {RedisAdapter} from "./RedisAdapter.js";
7
+
8
+ const REDIS_CONNECTION = Symbol.for("redis_connection");
9
+
10
+ registerConnectionProvider({
11
+ token: REDIS_CONNECTION
12
+ });
13
+
14
+ class Client {
15
+ @Property()
16
+ _id: string;
17
+
18
+ @Indexed()
19
+ name: string;
20
+
21
+ @Property()
22
+ @Required()
23
+ secret: string;
24
+ }
25
+
26
+ function createAdapterFixture<Model extends AdapterModel = Client>({
27
+ collectionName = "client",
28
+ model = Client
29
+ }: {collectionName?: string; model?: any} = {}) {
30
+ const adapter = IORedisTest.get<Adapters>(Adapters).invokeAdapter<Model>({
31
+ collectionName,
32
+ model,
33
+ adapter: RedisAdapter,
34
+ useHash: collectionName === "AuthorizationCode"
35
+ }) as RedisAdapter<Model>;
36
+
37
+ return {adapter};
38
+ }
39
+
40
+ describe("RedisAdapter", () => {
41
+ beforeEach(() => IORedisTest.create());
42
+ afterEach(() => IORedisTest.reset());
43
+
44
+ describe("create()", () => {
45
+ it("should create a new instance", async () => {
46
+ const {adapter} = await createAdapterFixture();
47
+
48
+ const base = {
49
+ name: "name",
50
+ secret: "secret"
51
+ };
52
+
53
+ const client = await adapter.create(base, new Date(Date.now() + 3000));
54
+
55
+ expect(client).toBeInstanceOf(Client);
56
+ expect(typeof client._id).toBe("string");
57
+ expect(client.name).toBe(base.name);
58
+
59
+ const keys = await adapter.db.keys("*");
60
+
61
+ expect(keys).toContain("client:" + client._id);
62
+ expect(keys).toContain(`$idx:client:${client._id}:name(${base.name})`);
63
+ });
64
+ });
65
+ describe("upsert()", () => {
66
+ it("should upsert a new instance", async () => {
67
+ const {adapter} = await createAdapterFixture({});
68
+
69
+ const base = deserialize<Client>(
70
+ {
71
+ name: "name",
72
+ secret: "secret"
73
+ },
74
+ {type: Client}
75
+ );
76
+ const id = "uuid";
77
+
78
+ const client = await adapter.upsert(id, base);
79
+ const client2 = await adapter.upsert(id, base);
80
+
81
+ expect(client).toBeInstanceOf(Client);
82
+ expect(typeof client._id).toBe("string");
83
+ expect(client._id).toBe(client2._id);
84
+ expect(client.name).toBe(base.name);
85
+
86
+ const keys = await adapter.db.keys("*");
87
+
88
+ expect(keys).toContain("client:" + client._id);
89
+ expect(keys).toContain(`$idx:client:${client._id}:name(${base.name})`);
90
+ });
91
+ });
92
+ describe("findById()", () => {
93
+ it("should find item by id (get)", async () => {
94
+ const {adapter} = await createAdapterFixture({});
95
+
96
+ const base = {
97
+ name: "name",
98
+ secret: "secret"
99
+ };
100
+
101
+ const client = await adapter.create(base);
102
+ const result = await adapter.findById(client._id);
103
+
104
+ expect(result).toBeInstanceOf(Client);
105
+ expect(result?._id).toBe(client._id);
106
+ expect(result?.name).toBe(base.name);
107
+ });
108
+ it("should handle no item found by id (get)", async () => {
109
+ const {adapter} = await createAdapterFixture({});
110
+
111
+ const result = await adapter.findById("does not exist");
112
+
113
+ expect(result).toBeFalsy();
114
+ });
115
+ it("should find item by id (hgetall)", async () => {
116
+ const {adapter} = await createAdapterFixture<any>({
117
+ collectionName: "AuthorizationCode",
118
+ model: Object
119
+ });
120
+
121
+ const base = {
122
+ key: "value"
123
+ };
124
+
125
+ const token = await adapter.create(base);
126
+ const result = await adapter.findById(token._id);
127
+
128
+ expect(result.key).toEqual(token.key);
129
+ });
130
+ it("should handle no item found by id (hgetall)", async () => {
131
+ const {adapter} = await createAdapterFixture<any>({
132
+ collectionName: "AuthorizationCode",
133
+ model: Object
134
+ });
135
+
136
+ const result = await adapter.findById("does not exist");
137
+
138
+ expect(result).toBeFalsy();
139
+ });
140
+ });
141
+ describe("findOne()", () => {
142
+ it("should find instance", async () => {
143
+ const {adapter} = await createAdapterFixture({});
144
+
145
+ const base = {
146
+ name: "name",
147
+ secret: "secret"
148
+ };
149
+
150
+ const client = await adapter.create(base);
151
+
152
+ const result = await adapter.findOne({
153
+ name: base.name
154
+ });
155
+
156
+ expect(result).toBeInstanceOf(Client);
157
+ expect(result?.name).toBe(base.name);
158
+ expect(result?._id).toBe(client._id);
159
+ });
160
+ it("should find instance by id", async () => {
161
+ const {adapter} = await createAdapterFixture({});
162
+
163
+ const base = {
164
+ name: "name",
165
+ secret: "secret"
166
+ };
167
+
168
+ const client = await adapter.create(base);
169
+
170
+ const result = await adapter.findOne({
171
+ _id: client._id,
172
+ name: base.name
173
+ });
174
+
175
+ expect(result).toBeInstanceOf(Client);
176
+ expect(result?._id).toBe(client._id);
177
+ expect(result?.name).toBe(base.name);
178
+ });
179
+ it("should not find data", async () => {
180
+ const {adapter} = await createAdapterFixture({});
181
+
182
+ const base = {
183
+ name: "name",
184
+ secret: "secret",
185
+ otherProp: "name"
186
+ };
187
+
188
+ await adapter.create(base);
189
+
190
+ const result = await adapter.findOne({
191
+ name: base.name,
192
+ otherProp: base.otherProp
193
+ });
194
+
195
+ expect(result).toBeUndefined();
196
+ });
197
+ });
198
+ describe("findAll()", () => {
199
+ it("should find all data (one prop)", async () => {
200
+ const {adapter} = await createAdapterFixture({});
201
+
202
+ const base = {
203
+ name: "name",
204
+ secret: "secret"
205
+ };
206
+
207
+ const client = await adapter.create(base);
208
+
209
+ const result = await adapter.findAll({
210
+ name: base.name
211
+ });
212
+
213
+ expect(result[0]).toBeInstanceOf(Client);
214
+ expect(result[0]?._id).toBe(client._id);
215
+ expect(result[0]?.name).toBe(base.name);
216
+ });
217
+ it("should find all by id (one prop)", async () => {
218
+ const {adapter} = await createAdapterFixture({});
219
+
220
+ const base = {
221
+ name: "name",
222
+ secret: "secret"
223
+ };
224
+
225
+ const client = await adapter.create(base);
226
+
227
+ const result = await adapter.findAll({
228
+ _id: client._id
229
+ });
230
+
231
+ expect(result[0]).toBeInstanceOf(Client);
232
+ expect(result[0]?._id).toBe(client._id);
233
+ expect(result[0]?.name).toBe(base.name);
234
+ });
235
+ it("should find all items", async () => {
236
+ const {adapter} = await createAdapterFixture({});
237
+
238
+ const base = {
239
+ name: "name",
240
+ secret: "secret"
241
+ };
242
+
243
+ const client = await adapter.create(base);
244
+
245
+ const result = await adapter.findAll({});
246
+
247
+ expect(result[0]).toBeInstanceOf(Client);
248
+ expect(result[0]?._id).toBe(client._id);
249
+ expect(result[0]?.name).toBe(base.name);
250
+ });
251
+ it("should not find data when predicate has an unknown prop", async () => {
252
+ const {adapter} = await createAdapterFixture({});
253
+
254
+ const base = {
255
+ name: "name",
256
+ secret: "secret",
257
+ otherProp: "name"
258
+ };
259
+
260
+ await adapter.create(base);
261
+
262
+ const result = await adapter.findAll({
263
+ name: base.name,
264
+ otherProp: base.otherProp
265
+ });
266
+
267
+ expect(result).toEqual([]);
268
+ });
269
+ });
270
+ describe("updateOne()", () => {
271
+ it("should update one item and merge props (undefined props are filtered)", async () => {
272
+ const {adapter} = await createAdapterFixture({});
273
+
274
+ const base = deserialize<Client>(
275
+ {
276
+ name: "name",
277
+ secret: "secret"
278
+ },
279
+ {type: Client}
280
+ );
281
+
282
+ const majItem = deserialize<Client>(
283
+ {
284
+ name: "name",
285
+ secret: "secret2"
286
+ },
287
+ {type: Client}
288
+ );
289
+
290
+ const client = await adapter.create(base);
291
+ const result = await adapter.updateOne(
292
+ {
293
+ name: base.name
294
+ },
295
+ majItem
296
+ );
297
+
298
+ expect(client).toBeInstanceOf(Client);
299
+ expect(typeof client._id).toBe("string");
300
+ expect(client._id).toBe(result?._id);
301
+ expect(client.name).toBe(base.name);
302
+ expect(client.secret).toBe("secret");
303
+ });
304
+ it("should not update item", async () => {
305
+ const {adapter} = await createAdapterFixture({});
306
+
307
+ const base = deserialize<Client>(
308
+ {
309
+ name: "name",
310
+ secret: "secret"
311
+ },
312
+ {type: Client}
313
+ );
314
+
315
+ const client = await adapter.create(base);
316
+ const result = await adapter.updateOne(
317
+ {
318
+ name: `${base.name}2`
319
+ },
320
+ base
321
+ );
322
+
323
+ expect(result).toBeUndefined();
324
+ expect(client.name).toBe(base.name);
325
+ });
326
+ });
327
+ describe("deleteOne()", () => {
328
+ it("should remove one item", async () => {
329
+ const {adapter} = await createAdapterFixture({});
330
+
331
+ const base = deserialize<Client>(
332
+ {
333
+ name: "name",
334
+ secret: "secret"
335
+ },
336
+ {type: Client}
337
+ );
338
+
339
+ const client = await adapter.create(base);
340
+ const result = await adapter.deleteOne({
341
+ name: base.name
342
+ });
343
+
344
+ expect(client).toBeInstanceOf(Client);
345
+ expect(typeof client._id).toBe("string");
346
+ expect(client._id).toBe(result?._id);
347
+ expect(client.name).toBe(base.name);
348
+ });
349
+ it("should not remove item", async () => {
350
+ const {adapter} = await createAdapterFixture({});
351
+
352
+ const base = deserialize<Client>(
353
+ {
354
+ name: "name",
355
+ secret: "secret"
356
+ },
357
+ {type: Client}
358
+ );
359
+
360
+ const client = await adapter.create(base);
361
+ const result = await adapter.deleteOne({
362
+ name: `${base.name}2`
363
+ });
364
+
365
+ expect(result).toBeUndefined();
366
+ expect(client.name).toBe(base.name);
367
+ });
368
+ });
369
+ describe("deleteById()", () => {
370
+ it("should remove one item", async () => {
371
+ const {adapter} = await createAdapterFixture({});
372
+
373
+ const base = deserialize<Client>(
374
+ {
375
+ name: "name",
376
+ secret: "secret"
377
+ },
378
+ {type: Client}
379
+ );
380
+
381
+ const client = await adapter.create(base);
382
+ const result = await adapter.deleteById(client._id);
383
+
384
+ expect(client).toBeInstanceOf(Client);
385
+ expect(typeof client._id).toBe("string");
386
+ expect(client._id).toBe(result?._id);
387
+ expect(client.name).toBe(base.name);
388
+ });
389
+ });
390
+ describe("deleteMany()", () => {
391
+ it("should remove items", async () => {
392
+ const {adapter} = await createAdapterFixture({});
393
+
394
+ const base = deserialize<Client>(
395
+ {
396
+ name: "name",
397
+ secret: "secret"
398
+ },
399
+ {type: Client}
400
+ );
401
+
402
+ const client = await adapter.create(base);
403
+ const result = await adapter.deleteMany({
404
+ name: base.name
405
+ });
406
+
407
+ expect(result[0]).toBeInstanceOf(Client);
408
+ expect(typeof result[0]._id).toBe("string");
409
+ expect(result[0]._id).toBe(client?._id);
410
+ expect(result[0].name).toBe(base.name);
411
+ });
412
+ });
413
+ });
@@ -0,0 +1,280 @@
1
+ import {Adapter, AdapterConstructorOptions, AdapterModel} from "@tsed/adapters";
2
+ import {cleanObject, isObject, isString} from "@tsed/core";
3
+ import {Configuration, Inject, Opts} from "@tsed/di";
4
+ import {Hooks} from "@tsed/hooks";
5
+ import {IORedis, IOREDIS_CONNECTIONS} from "@tsed/ioredis";
6
+ import type {ChainableCommander, Redis} from "ioredis";
7
+ import {v4 as uuid} from "uuid";
8
+
9
+ const getId = (key: string) => key.split(":")[2];
10
+ const flatKeys = (keys: [Error | null, string[]][]): string[] => {
11
+ return keys.flatMap(([, result]) => result).filter(Boolean);
12
+ };
13
+
14
+ export interface RedisAdapterConstructorOptions extends AdapterConstructorOptions {
15
+ connectionName?: string;
16
+ useHash?: boolean;
17
+ }
18
+
19
+ export class RedisAdapter<Model extends AdapterModel> extends Adapter<Model> {
20
+ readonly hooks = new Hooks();
21
+ readonly connectionName: string;
22
+ readonly connection: IORedis;
23
+ protected useHash: boolean = false;
24
+
25
+ constructor(
26
+ @Opts options: RedisAdapterConstructorOptions,
27
+ @Inject(IOREDIS_CONNECTIONS) connections: IORedis[],
28
+ @Configuration() protected configuration: Configuration
29
+ ) {
30
+ super(options, configuration);
31
+
32
+ this.useHash = Boolean(options.useHash);
33
+ this.connectionName = options.connectionName || "default";
34
+ this.connection = connections.find((connection) => connection.name === this.connectionName)!; // || connections[0];
35
+ }
36
+
37
+ get db(): Redis {
38
+ return this.connection;
39
+ }
40
+
41
+ key(id: string) {
42
+ return `${this.collectionName}:${id}`;
43
+ }
44
+
45
+ public create(payload: Partial<Model>, expiresAt?: Date): Promise<Model> {
46
+ delete payload._id;
47
+ return this.insert(payload, expiresAt);
48
+ }
49
+
50
+ public async upsert(id: string, payload: Model, expiresAt?: Date): Promise<Model> {
51
+ const item = await this.findById(id);
52
+
53
+ if (!item) {
54
+ payload._id = id;
55
+
56
+ return this.insert(payload, expiresAt);
57
+ }
58
+
59
+ return this.update(id, payload, expiresAt) as unknown as Promise<Model>;
60
+ }
61
+
62
+ public update(id: string, payload: Model, expiresAt?: Date): Promise<Model | undefined> {
63
+ return this.updateOne({_id: id}, payload, expiresAt);
64
+ }
65
+
66
+ public async updateOne(predicate: Partial<Model & any>, payload: Model, expiresAt?: Date): Promise<Model | undefined> {
67
+ const item = await this.findOne(predicate);
68
+
69
+ if (!item) {
70
+ return undefined;
71
+ }
72
+
73
+ Object.assign(item, cleanObject(payload), {
74
+ _id: item._id
75
+ });
76
+
77
+ return this.insert(item, expiresAt);
78
+ }
79
+
80
+ async findOne(predicate: Partial<Model & any>): Promise<Model | undefined> {
81
+ const {_id, ...props} = predicate;
82
+
83
+ if (_id) {
84
+ return this.findById(_id);
85
+ }
86
+
87
+ const keys = Object.keys(props);
88
+ let foundKeys = await this.findKeys(props);
89
+
90
+ if (foundKeys.length < keys.length) {
91
+ return undefined;
92
+ }
93
+
94
+ const getId = (key: string) => key.split(":")[2];
95
+ const id = getId(foundKeys[0]);
96
+
97
+ foundKeys = foundKeys.filter((key) => id === getId(key));
98
+
99
+ return keys.length === foundKeys.length ? this.findById(id) : undefined;
100
+ }
101
+
102
+ async findById(_id: string): Promise<Model | undefined> {
103
+ const key = this.key(_id);
104
+
105
+ const item = this.useHash ? await this.db.hgetall(key) : await this.db.get(key);
106
+
107
+ // hgetall returns an empty object when there are no results, so we need to check for that.
108
+ if (!item || (this.useHash && isObject(item) && Object.keys(item).length === 0)) {
109
+ return undefined;
110
+ }
111
+
112
+ if (isString(item)) {
113
+ return this.deserialize(JSON.parse(item));
114
+ }
115
+
116
+ const {payload, ...rest} = item;
117
+
118
+ return {
119
+ ...rest,
120
+ ...(payload ? JSON.parse(payload) : {})
121
+ };
122
+ }
123
+
124
+ public async findAll(predicate: Partial<Model & any> = {}): Promise<Model[]> {
125
+ const {_id, ...props} = predicate;
126
+
127
+ if (_id) {
128
+ const item = await this.findById(_id);
129
+ return item ? [item] : [];
130
+ }
131
+
132
+ const keys = Object.keys(props);
133
+
134
+ if (keys.length === 0) {
135
+ return this.getAll();
136
+ }
137
+
138
+ return this.findAllBy(props);
139
+ }
140
+
141
+ public async deleteOne(predicate: Partial<Model & any>): Promise<Model | undefined> {
142
+ const item = await this.findOne(predicate);
143
+
144
+ if (item) {
145
+ const id = this.key(item._id);
146
+ const indexIds = await this.getAllIndex(item._id);
147
+
148
+ await this.db.del([id, ...indexIds]);
149
+
150
+ return this.deserialize(item);
151
+ }
152
+ }
153
+
154
+ public deleteById(_id: string): Promise<Model | undefined> {
155
+ return this.deleteOne({_id} as any);
156
+ }
157
+
158
+ public async deleteMany(predicate: Partial<Model>): Promise<Model[]> {
159
+ const items = await this.findAll(predicate);
160
+
161
+ const pipeline = this.db.pipeline();
162
+ const ids: string[] = [];
163
+
164
+ const results = items.map((item) => {
165
+ ids.push(this.key(item._id));
166
+
167
+ this.getAllIndex(item._id, pipeline);
168
+
169
+ return this.deserialize(item);
170
+ });
171
+
172
+ const keys = await pipeline.exec();
173
+
174
+ this.db.del([...ids, ...flatKeys(keys as any)]);
175
+
176
+ return results;
177
+ }
178
+
179
+ protected async findKeys(props: any): Promise<string[]> {
180
+ const keys: any[] = Object.keys(props);
181
+ const pipeline = this.db.pipeline();
182
+
183
+ this.indexes
184
+ .filter(({propertyKey}) => keys.includes(propertyKey))
185
+ .forEach(({propertyKey}) => {
186
+ const value = props[propertyKey];
187
+ const patterns = this.getIndexedKey("*", propertyKey, value);
188
+
189
+ pipeline.keys(patterns);
190
+ });
191
+
192
+ const results = await pipeline.exec();
193
+
194
+ return flatKeys(results as any[]);
195
+ }
196
+
197
+ protected async insert(payload: Partial<Model>, expiresAt?: Date) {
198
+ const id = (payload._id = payload._id || uuid());
199
+
200
+ const expiresIn = expiresAt ? expiresAt.getTime() - Date.now() : null;
201
+
202
+ await this.validate(payload as Model);
203
+
204
+ const multi = this.db.multi();
205
+ const key = this.key(id);
206
+
207
+ const strPayload = JSON.stringify(this.serialize(payload));
208
+
209
+ this.useHash ? await multi.hset(key, {payload: strPayload}) : await multi.set(key, strPayload);
210
+
211
+ if (expiresIn) {
212
+ multi.expire(key, expiresIn);
213
+ }
214
+
215
+ this.indexes.forEach(({propertyKey}) => {
216
+ const value = payload[propertyKey];
217
+ const indexedKey = this.getIndexedKey(id, propertyKey, value);
218
+
219
+ multi.set(indexedKey, id);
220
+ expiresIn && multi.expire(indexedKey, expiresIn);
221
+ });
222
+
223
+ await this.hooks.asyncAlter("insert", multi, [payload, expiresIn]);
224
+
225
+ await multi.exec();
226
+
227
+ const result = this.deserialize(payload, {useAlias: false});
228
+
229
+ await this.hooks.asyncEmit("afterInsert", [result]);
230
+
231
+ return result;
232
+ }
233
+
234
+ protected getAllIndex(id: string): Promise<string[]>;
235
+ protected getAllIndex(id: string, pipeline: ChainableCommander): ChainableCommander;
236
+ protected getAllIndex(id: string, pipeline?: ChainableCommander) {
237
+ const key = ["$idx", this.key(id), "*"].join(":");
238
+
239
+ return (pipeline || this.db).keys(key);
240
+ }
241
+
242
+ protected getIndexedKey(id: string, propertyKey: string, value: any): string {
243
+ const key = this.key(id);
244
+ return ["$idx", key, `${propertyKey}(${value})`].map(String).join(":");
245
+ }
246
+
247
+ protected async getAll(): Promise<Model[]> {
248
+ const keys = await this.db.keys(`${this.collectionName}:*`);
249
+ const pipeline = this.db.pipeline();
250
+
251
+ keys.forEach((key) => {
252
+ pipeline.get(key);
253
+ });
254
+
255
+ const result = await pipeline.exec();
256
+ return (result || []).map(([, data]: [any, string]) => this.deserialize(JSON.parse(data))).filter(Boolean);
257
+ }
258
+
259
+ protected async findAllBy(props: Partial<Model & any>): Promise<Model[]> {
260
+ const keys = Object.keys(props);
261
+ const foundKeys = await this.findKeys(props);
262
+
263
+ if (foundKeys.length < keys.length) {
264
+ return [];
265
+ }
266
+
267
+ const map = foundKeys.reduce((map: Map<string, number>, key) => {
268
+ const id = getId(key);
269
+ const value = map.get(id) || 0;
270
+
271
+ return map.set(id, value + 1);
272
+ }, new Map());
273
+
274
+ const promises = [...map.entries()].filter(([, num]) => num === keys.length).map(([id]) => this.findById(id));
275
+
276
+ const result = await Promise.all(promises);
277
+
278
+ return result.filter(Boolean) as Model[];
279
+ }
280
+ }
@@ -0,0 +1,12 @@
1
+ import {AdaptersSettings} from "@tsed/adapters";
2
+ import {RedisOptions} from "ioredis";
3
+
4
+ declare global {
5
+ namespace TsED {
6
+ interface AdaptersOptions extends AdaptersSettings {
7
+ redis: RedisOptions & {
8
+ indexes?: {[propertyKey: string]: Record<string, any>};
9
+ };
10
+ }
11
+ }
12
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ /**
2
+ * @file Automatically generated by @tsed/barrels.
3
+ */
4
+ export * from "./adapters/OIDCRedisAdapter.js";
5
+ export * from "./adapters/RedisAdapter.js";
6
+ export * from "./domain/RedisAdaptersSettings.js";