ts-cache-mongoose 1.7.7 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +88 -66
- package/biome.json +1 -1
- package/dist/index.cjs +140 -93
- package/dist/index.d.cts +48 -11
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +48 -11
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +140 -93
- package/dist/nest/index.cjs +113 -0
- package/dist/nest/index.d.cts +40 -0
- package/dist/nest/index.d.cts.map +1 -0
- package/dist/nest/index.d.mts +40 -0
- package/dist/nest/index.d.mts.map +1 -0
- package/dist/nest/index.mjs +110 -0
- package/package.json +44 -22
- package/src/cache/Cache.ts +5 -5
- package/src/cache/engine/MemoryCacheEngine.ts +4 -4
- package/src/cache/engine/RedisCacheEngine.ts +4 -4
- package/src/extend/aggregate.ts +5 -6
- package/src/extend/query.ts +5 -6
- package/src/index.ts +7 -7
- package/src/key.ts +2 -2
- package/src/ms.ts +66 -0
- package/src/nest/cache.module.ts +79 -0
- package/src/nest/cache.service.ts +37 -0
- package/src/nest/index.ts +4 -0
- package/src/nest/interfaces.ts +17 -0
- package/src/sort-keys.ts +38 -0
- package/src/types.ts +4 -4
- package/src/version.ts +1 -2
- package/tests/ms.test.ts +113 -0
- package/tests/nest.test.ts +158 -0
- package/tests/sort-keys.test.ts +80 -0
- package/tsconfig.json +3 -3
package/src/ms.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
const s = 1000
|
|
2
|
+
const m = s * 60
|
|
3
|
+
const h = m * 60
|
|
4
|
+
const d = h * 24
|
|
5
|
+
const w = d * 7
|
|
6
|
+
const y = d * 365.25
|
|
7
|
+
const mo = y / 12
|
|
8
|
+
|
|
9
|
+
export const UNITS = {
|
|
10
|
+
milliseconds: 1,
|
|
11
|
+
millisecond: 1,
|
|
12
|
+
msecs: 1,
|
|
13
|
+
msec: 1,
|
|
14
|
+
ms: 1,
|
|
15
|
+
seconds: s,
|
|
16
|
+
second: s,
|
|
17
|
+
secs: s,
|
|
18
|
+
sec: s,
|
|
19
|
+
s,
|
|
20
|
+
minutes: m,
|
|
21
|
+
minute: m,
|
|
22
|
+
mins: m,
|
|
23
|
+
min: m,
|
|
24
|
+
m,
|
|
25
|
+
hours: h,
|
|
26
|
+
hour: h,
|
|
27
|
+
hrs: h,
|
|
28
|
+
hr: h,
|
|
29
|
+
h,
|
|
30
|
+
days: d,
|
|
31
|
+
day: d,
|
|
32
|
+
d,
|
|
33
|
+
weeks: w,
|
|
34
|
+
week: w,
|
|
35
|
+
w,
|
|
36
|
+
months: mo,
|
|
37
|
+
month: mo,
|
|
38
|
+
mo,
|
|
39
|
+
years: y,
|
|
40
|
+
year: y,
|
|
41
|
+
yrs: y,
|
|
42
|
+
yr: y,
|
|
43
|
+
y,
|
|
44
|
+
} as const satisfies Record<string, number>
|
|
45
|
+
|
|
46
|
+
export type Unit = keyof typeof UNITS
|
|
47
|
+
|
|
48
|
+
export type Duration = number | `${number}` | `${number}${Unit}` | `${number} ${Unit}`
|
|
49
|
+
|
|
50
|
+
const unitPattern = Object.keys(UNITS)
|
|
51
|
+
.sort((a, b) => b.length - a.length)
|
|
52
|
+
.join('|')
|
|
53
|
+
|
|
54
|
+
const RE = new RegExp(String.raw`^(-?(?:\d+)?\.?\d+)\s*(${unitPattern})?$`, 'i')
|
|
55
|
+
|
|
56
|
+
export const ms = (val: Duration): number => {
|
|
57
|
+
const str = String(val)
|
|
58
|
+
if (str.length > 100) return Number.NaN
|
|
59
|
+
|
|
60
|
+
const match = RE.exec(str)
|
|
61
|
+
if (!match) return Number.NaN
|
|
62
|
+
|
|
63
|
+
const n = Number.parseFloat(match[1] ?? '')
|
|
64
|
+
const type = (match[2] ?? 'ms').toLowerCase()
|
|
65
|
+
return n * (UNITS[type as Unit] ?? 0)
|
|
66
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/** biome-ignore-all lint/complexity/noStaticOnlyClass: nest */
|
|
2
|
+
import { Module } from '@nestjs/common'
|
|
3
|
+
import { CACHE_OPTIONS, CacheService } from './cache.service'
|
|
4
|
+
|
|
5
|
+
import type { DynamicModule, Provider } from '@nestjs/common'
|
|
6
|
+
import type { CacheModuleAsyncOptions, CacheModuleOptions, CacheOptionsFactory } from './interfaces'
|
|
7
|
+
|
|
8
|
+
@Module({})
|
|
9
|
+
export class CacheModule {
|
|
10
|
+
static forRoot(options: CacheModuleOptions & { isGlobal?: boolean }): DynamicModule {
|
|
11
|
+
return {
|
|
12
|
+
module: CacheModule,
|
|
13
|
+
global: options.isGlobal ?? false,
|
|
14
|
+
providers: [
|
|
15
|
+
{ provide: CACHE_OPTIONS, useValue: options },
|
|
16
|
+
{
|
|
17
|
+
provide: CacheService,
|
|
18
|
+
useFactory: (opts: CacheModuleOptions) => new CacheService(opts),
|
|
19
|
+
inject: [CACHE_OPTIONS],
|
|
20
|
+
},
|
|
21
|
+
],
|
|
22
|
+
exports: [CacheService],
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
static forRootAsync(options: CacheModuleAsyncOptions): DynamicModule {
|
|
27
|
+
const asyncProviders = CacheModule.createAsyncProviders(options)
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
module: CacheModule,
|
|
31
|
+
global: options.isGlobal ?? false,
|
|
32
|
+
imports: options.imports ?? [],
|
|
33
|
+
providers: [
|
|
34
|
+
...asyncProviders,
|
|
35
|
+
{
|
|
36
|
+
provide: CacheService,
|
|
37
|
+
useFactory: (opts: CacheModuleOptions) => new CacheService(opts),
|
|
38
|
+
inject: [CACHE_OPTIONS],
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
exports: [CacheService],
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
private static createAsyncProviders(options: CacheModuleAsyncOptions): Provider[] {
|
|
46
|
+
if (options.useFactory) {
|
|
47
|
+
return [
|
|
48
|
+
{
|
|
49
|
+
provide: CACHE_OPTIONS,
|
|
50
|
+
useFactory: options.useFactory,
|
|
51
|
+
inject: options.inject ?? [],
|
|
52
|
+
},
|
|
53
|
+
]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (options.useClass) {
|
|
57
|
+
return [
|
|
58
|
+
{ provide: options.useClass, useClass: options.useClass },
|
|
59
|
+
{
|
|
60
|
+
provide: CACHE_OPTIONS,
|
|
61
|
+
useFactory: (factory: CacheOptionsFactory) => factory.createCacheOptions(),
|
|
62
|
+
inject: [options.useClass],
|
|
63
|
+
},
|
|
64
|
+
]
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (options.useExisting) {
|
|
68
|
+
return [
|
|
69
|
+
{
|
|
70
|
+
provide: CACHE_OPTIONS,
|
|
71
|
+
useFactory: (factory: CacheOptionsFactory) => factory.createCacheOptions(),
|
|
72
|
+
inject: [options.useExisting],
|
|
73
|
+
},
|
|
74
|
+
]
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return []
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Logger } from '@nestjs/common'
|
|
2
|
+
import mongoose from 'mongoose'
|
|
3
|
+
import CacheMongoose from '../index'
|
|
4
|
+
|
|
5
|
+
import type { OnApplicationBootstrap, OnApplicationShutdown } from '@nestjs/common'
|
|
6
|
+
import type { CacheModuleOptions } from './interfaces'
|
|
7
|
+
|
|
8
|
+
export const CACHE_OPTIONS = Symbol('CACHE_OPTIONS')
|
|
9
|
+
|
|
10
|
+
export class CacheService implements OnApplicationBootstrap, OnApplicationShutdown {
|
|
11
|
+
private readonly logger = new Logger(CacheService.name)
|
|
12
|
+
private readonly options: CacheModuleOptions
|
|
13
|
+
private cacheMongoose!: CacheMongoose
|
|
14
|
+
|
|
15
|
+
constructor(options: CacheModuleOptions) {
|
|
16
|
+
this.options = options
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
get instance(): CacheMongoose {
|
|
20
|
+
return this.cacheMongoose
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async onApplicationBootstrap(): Promise<void> {
|
|
24
|
+
this.cacheMongoose = CacheMongoose.init(mongoose, this.options)
|
|
25
|
+
this.logger.log(`Cache initialized with ${this.options.engine} engine`)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async onApplicationShutdown(): Promise<void> {
|
|
29
|
+
if (this.cacheMongoose) {
|
|
30
|
+
await this.cacheMongoose.close()
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async clear(customKey?: string): Promise<void> {
|
|
35
|
+
await this.cacheMongoose.clear(customKey)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { InjectionToken, ModuleMetadata, OptionalFactoryDependency, Type } from '@nestjs/common'
|
|
2
|
+
import type { CacheOptions } from '../types'
|
|
3
|
+
|
|
4
|
+
export type CacheModuleOptions = CacheOptions
|
|
5
|
+
|
|
6
|
+
export interface CacheOptionsFactory {
|
|
7
|
+
createCacheOptions(): CacheModuleOptions | Promise<CacheModuleOptions>
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface CacheModuleAsyncOptions extends Pick<ModuleMetadata, 'imports'> {
|
|
11
|
+
isGlobal?: boolean
|
|
12
|
+
inject?: (InjectionToken | OptionalFactoryDependency)[]
|
|
13
|
+
useClass?: Type<CacheOptionsFactory>
|
|
14
|
+
useExisting?: Type<CacheOptionsFactory>
|
|
15
|
+
// biome-ignore lint/suspicious/noExplicitAny: NestJS convention for factory args
|
|
16
|
+
useFactory?: (...args: any[]) => CacheModuleOptions | Promise<CacheModuleOptions>
|
|
17
|
+
}
|
package/src/sort-keys.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
|
|
2
|
+
if (typeof value !== 'object' || value === null) return false
|
|
3
|
+
const proto = Object.getPrototypeOf(value) as unknown
|
|
4
|
+
return proto === Object.prototype || proto === null
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export const sortKeys = (input: Record<string, unknown> | Record<string, unknown>[]): Record<string, unknown> | Record<string, unknown>[] => {
|
|
8
|
+
const seen = new WeakSet<object>()
|
|
9
|
+
|
|
10
|
+
const sortObject = (obj: Record<string, unknown>): Record<string, unknown> => {
|
|
11
|
+
if (seen.has(obj)) return obj
|
|
12
|
+
seen.add(obj)
|
|
13
|
+
|
|
14
|
+
const sorted: Record<string, unknown> = {}
|
|
15
|
+
for (const key of Object.keys(obj).sort((a, b) => a.localeCompare(b))) {
|
|
16
|
+
const value = obj[key]
|
|
17
|
+
if (Array.isArray(value)) {
|
|
18
|
+
sorted[key] = sortArray(value)
|
|
19
|
+
} else if (isPlainObject(value)) {
|
|
20
|
+
sorted[key] = sortObject(value)
|
|
21
|
+
} else {
|
|
22
|
+
sorted[key] = value
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return sorted
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const sortArray = (arr: unknown[]): unknown[] => {
|
|
29
|
+
return arr.map((item) => {
|
|
30
|
+
if (Array.isArray(item)) return sortArray(item)
|
|
31
|
+
if (isPlainObject(item)) return sortObject(item)
|
|
32
|
+
return item
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (Array.isArray(input)) return sortArray(input) as Record<string, unknown>[]
|
|
37
|
+
return sortObject(input)
|
|
38
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
import type { RedisOptions } from 'ioredis'
|
|
2
|
-
import type {
|
|
2
|
+
import type { Duration } from './ms'
|
|
3
3
|
|
|
4
|
-
export type
|
|
4
|
+
export type { Duration } from './ms'
|
|
5
5
|
|
|
6
6
|
export type CacheData = Record<string, unknown> | Record<string, unknown>[] | unknown[] | number | undefined
|
|
7
7
|
|
|
8
8
|
export type CacheOptions = {
|
|
9
9
|
engine: 'memory' | 'redis'
|
|
10
10
|
engineOptions?: RedisOptions
|
|
11
|
-
defaultTTL?:
|
|
11
|
+
defaultTTL?: Duration
|
|
12
12
|
debug?: boolean
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
export interface CacheEngine {
|
|
16
16
|
get: (key: string) => Promise<CacheData> | CacheData
|
|
17
|
-
set: (key: string, value: CacheData, ttl?:
|
|
17
|
+
set: (key: string, value: CacheData, ttl?: Duration) => Promise<void> | void
|
|
18
18
|
del: (key: string) => Promise<void> | void
|
|
19
19
|
clear: () => Promise<void> | void
|
|
20
20
|
close: () => Promise<void> | void
|
package/src/version.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import mongoose from 'mongoose'
|
|
2
|
-
import { satisfies } from 'semver'
|
|
3
2
|
|
|
4
3
|
import type { CacheData } from './types'
|
|
5
4
|
|
|
6
|
-
export const isMongooseLessThan7 =
|
|
5
|
+
export const isMongooseLessThan7 = Number.parseInt(mongoose.version, 10) < 7
|
|
7
6
|
|
|
8
7
|
export const convertToObject = <T>(value: (T & { toObject?: () => CacheData }) | undefined): CacheData => {
|
|
9
8
|
if (isMongooseLessThan7) {
|
package/tests/ms.test.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { ms, UNITS } from '../src/ms'
|
|
4
|
+
|
|
5
|
+
const { s, m, h, d, w, mo, y } = UNITS
|
|
6
|
+
|
|
7
|
+
describe('ms', () => {
|
|
8
|
+
it('should parse milliseconds', () => {
|
|
9
|
+
expect(ms('100ms')).toBe(100)
|
|
10
|
+
expect(ms('500 milliseconds')).toBe(500)
|
|
11
|
+
expect(ms('1 millisecond')).toBe(1)
|
|
12
|
+
expect(ms('200 msec')).toBe(200)
|
|
13
|
+
expect(ms('300 msecs')).toBe(300)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('should parse seconds', () => {
|
|
17
|
+
expect(ms('1s')).toBe(s)
|
|
18
|
+
expect(ms('5 seconds')).toBe(5 * s)
|
|
19
|
+
expect(ms('30 sec')).toBe(30 * s)
|
|
20
|
+
expect(ms('1 second')).toBe(s)
|
|
21
|
+
expect(ms('2 secs')).toBe(2 * s)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('should parse minutes', () => {
|
|
25
|
+
expect(ms('1m')).toBe(m)
|
|
26
|
+
expect(ms('5 minutes')).toBe(5 * m)
|
|
27
|
+
expect(ms('1 minute')).toBe(m)
|
|
28
|
+
expect(ms('2 min')).toBe(2 * m)
|
|
29
|
+
expect(ms('3 mins')).toBe(3 * m)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('should parse hours', () => {
|
|
33
|
+
expect(ms('1h')).toBe(h)
|
|
34
|
+
expect(ms('2 hours')).toBe(2 * h)
|
|
35
|
+
expect(ms('1 hour')).toBe(h)
|
|
36
|
+
expect(ms('3 hr')).toBe(3 * h)
|
|
37
|
+
expect(ms('4 hrs')).toBe(4 * h)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('should parse days', () => {
|
|
41
|
+
expect(ms('1d')).toBe(d)
|
|
42
|
+
expect(ms('2 days')).toBe(2 * d)
|
|
43
|
+
expect(ms('1 day')).toBe(d)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('should parse weeks', () => {
|
|
47
|
+
expect(ms('1w')).toBe(w)
|
|
48
|
+
expect(ms('2 weeks')).toBe(2 * w)
|
|
49
|
+
expect(ms('1 week')).toBe(w)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('should parse months', () => {
|
|
53
|
+
expect(ms('1mo')).toBe(mo)
|
|
54
|
+
expect(ms('1 month')).toBe(mo)
|
|
55
|
+
expect(ms('2 months')).toBe(2 * mo)
|
|
56
|
+
expect(ms('6mo')).toBe(6 * mo)
|
|
57
|
+
expect(ms('0.5mo')).toBe(0.5 * mo)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('should parse years', () => {
|
|
61
|
+
expect(ms('1y')).toBe(y)
|
|
62
|
+
expect(ms('1 year')).toBe(y)
|
|
63
|
+
expect(ms('2 yrs')).toBe(2 * y)
|
|
64
|
+
expect(ms('1 yr')).toBe(y)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('should parse decimal values', () => {
|
|
68
|
+
expect(ms('1.5h')).toBe(1.5 * h)
|
|
69
|
+
expect(ms('0.5d')).toBe(0.5 * d)
|
|
70
|
+
expect(ms('.5s')).toBe(0.5 * s)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('should parse negative values', () => {
|
|
74
|
+
expect(ms('-1s')).toBe(-s)
|
|
75
|
+
expect(ms('-3m')).toBe(-3 * m)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('should default to milliseconds without unit', () => {
|
|
79
|
+
expect(ms('100')).toBe(100)
|
|
80
|
+
expect(ms('0')).toBe(0)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('should be case insensitive', () => {
|
|
84
|
+
// @ts-expect-error runtime check
|
|
85
|
+
expect(ms('1S')).toBe(s)
|
|
86
|
+
// @ts-expect-error runtime check
|
|
87
|
+
expect(ms('1M')).toBe(m)
|
|
88
|
+
// @ts-expect-error runtime check
|
|
89
|
+
expect(ms('1H')).toBe(h)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('should return NaN for invalid strings', () => {
|
|
93
|
+
// @ts-expect-error testing invalid input
|
|
94
|
+
expect(ms('invalid')).toBeNaN()
|
|
95
|
+
// @ts-expect-error testing invalid input
|
|
96
|
+
expect(ms('')).toBeNaN()
|
|
97
|
+
// @ts-expect-error testing invalid input
|
|
98
|
+
expect(ms('abc123')).toBeNaN()
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('should return NaN for strings longer than 100 characters', () => {
|
|
102
|
+
// @ts-expect-error testing invalid input
|
|
103
|
+
expect(ms('a'.repeat(101))).toBeNaN()
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('should handle whitespace between number and unit', () => {
|
|
107
|
+
expect(ms('1 s')).toBe(s)
|
|
108
|
+
expect(ms('5 minutes')).toBe(5 * m)
|
|
109
|
+
expect(ms('1 mo')).toBe(mo)
|
|
110
|
+
expect(ms('1 week')).toBe(w)
|
|
111
|
+
expect(ms('1 year')).toBe(y)
|
|
112
|
+
})
|
|
113
|
+
})
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { CacheModule } from '../src/nest/cache.module'
|
|
4
|
+
import { CACHE_OPTIONS, CacheService } from '../src/nest/cache.service'
|
|
5
|
+
|
|
6
|
+
vi.mock('@nestjs/common', () => {
|
|
7
|
+
const LoggerMock = class {
|
|
8
|
+
log = vi.fn()
|
|
9
|
+
}
|
|
10
|
+
return {
|
|
11
|
+
Module: () => () => {},
|
|
12
|
+
Logger: LoggerMock,
|
|
13
|
+
}
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
vi.mock('../src/index', () => ({
|
|
17
|
+
default: {
|
|
18
|
+
init: vi.fn().mockReturnValue({
|
|
19
|
+
clear: vi.fn(),
|
|
20
|
+
close: vi.fn(),
|
|
21
|
+
}),
|
|
22
|
+
},
|
|
23
|
+
}))
|
|
24
|
+
|
|
25
|
+
const defaultOptions = { engine: 'memory' as const, defaultTTL: '60 seconds' }
|
|
26
|
+
|
|
27
|
+
describe('CacheModule', () => {
|
|
28
|
+
describe('forRoot', () => {
|
|
29
|
+
it('should return a dynamic module with providers', () => {
|
|
30
|
+
const result = CacheModule.forRoot(defaultOptions)
|
|
31
|
+
expect(result.module).toBe(CacheModule)
|
|
32
|
+
expect(result.providers).toBeDefined()
|
|
33
|
+
expect(result.exports).toContain(CacheService)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('should set global to false by default', () => {
|
|
37
|
+
const result = CacheModule.forRoot(defaultOptions)
|
|
38
|
+
expect(result.global).toBe(false)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('should set global when isGlobal is true', () => {
|
|
42
|
+
const result = CacheModule.forRoot({ ...defaultOptions, isGlobal: true })
|
|
43
|
+
expect(result.global).toBe(true)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('should provide CACHE_OPTIONS with useValue', () => {
|
|
47
|
+
const result = CacheModule.forRoot(defaultOptions)
|
|
48
|
+
const optionsProvider = (result.providers as { provide: symbol; useValue: unknown }[]).find((p) => p.provide === CACHE_OPTIONS)
|
|
49
|
+
expect(optionsProvider).toBeDefined()
|
|
50
|
+
expect(optionsProvider?.useValue).toEqual(defaultOptions)
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
describe('forRootAsync', () => {
|
|
55
|
+
it('should return a dynamic module with useFactory', () => {
|
|
56
|
+
const result = CacheModule.forRootAsync({
|
|
57
|
+
useFactory: () => defaultOptions,
|
|
58
|
+
})
|
|
59
|
+
expect(result.module).toBe(CacheModule)
|
|
60
|
+
expect(result.providers).toBeDefined()
|
|
61
|
+
expect(result.exports).toContain(CacheService)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('should support useClass', () => {
|
|
65
|
+
class TestFactory {
|
|
66
|
+
createCacheOptions() {
|
|
67
|
+
return defaultOptions
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const result = CacheModule.forRootAsync({ useClass: TestFactory })
|
|
71
|
+
expect(result.providers).toBeDefined()
|
|
72
|
+
expect(result.providers?.length).toBeGreaterThan(1)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('should support useExisting', () => {
|
|
76
|
+
class TestFactory {
|
|
77
|
+
createCacheOptions() {
|
|
78
|
+
return defaultOptions
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const result = CacheModule.forRootAsync({ useExisting: TestFactory })
|
|
82
|
+
expect(result.providers).toBeDefined()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('should return empty providers when no factory method', () => {
|
|
86
|
+
const result = CacheModule.forRootAsync({})
|
|
87
|
+
const providers = result.providers as unknown[]
|
|
88
|
+
const serviceProvider = providers.find((p) => typeof p === 'object' && p !== null && 'provide' in p && (p as { provide: unknown }).provide === CacheService)
|
|
89
|
+
expect(serviceProvider).toBeDefined()
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('should pass imports through', () => {
|
|
93
|
+
const result = CacheModule.forRootAsync({
|
|
94
|
+
imports: [],
|
|
95
|
+
useFactory: () => defaultOptions,
|
|
96
|
+
})
|
|
97
|
+
expect(result.imports).toEqual([])
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('should set global when isGlobal is true', () => {
|
|
101
|
+
const result = CacheModule.forRootAsync({
|
|
102
|
+
isGlobal: true,
|
|
103
|
+
useFactory: () => defaultOptions,
|
|
104
|
+
})
|
|
105
|
+
expect(result.global).toBe(true)
|
|
106
|
+
})
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
describe('CacheService', () => {
|
|
111
|
+
it('should store options', () => {
|
|
112
|
+
const service = new CacheService(defaultOptions)
|
|
113
|
+
expect(service).toBeDefined()
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('should initialize cache on bootstrap', async () => {
|
|
117
|
+
const service = new CacheService(defaultOptions)
|
|
118
|
+
await service.onApplicationBootstrap()
|
|
119
|
+
expect(service.instance).toBeDefined()
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('should close cache on shutdown', async () => {
|
|
123
|
+
const service = new CacheService(defaultOptions)
|
|
124
|
+
await service.onApplicationBootstrap()
|
|
125
|
+
const closeSpy = vi.spyOn(service.instance, 'close')
|
|
126
|
+
await service.onApplicationShutdown()
|
|
127
|
+
expect(closeSpy).toHaveBeenCalled()
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('should not throw on shutdown when not initialized', async () => {
|
|
131
|
+
const service = new CacheService(defaultOptions)
|
|
132
|
+
await expect(service.onApplicationShutdown()).resolves.not.toThrow()
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('should clear cache with custom key', async () => {
|
|
136
|
+
const service = new CacheService(defaultOptions)
|
|
137
|
+
await service.onApplicationBootstrap()
|
|
138
|
+
const clearSpy = vi.spyOn(service.instance, 'clear')
|
|
139
|
+
await service.clear('test-key')
|
|
140
|
+
expect(clearSpy).toHaveBeenCalledWith('test-key')
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('should clear all cache without key', async () => {
|
|
144
|
+
const service = new CacheService(defaultOptions)
|
|
145
|
+
await service.onApplicationBootstrap()
|
|
146
|
+
const clearSpy = vi.spyOn(service.instance, 'clear')
|
|
147
|
+
await service.clear()
|
|
148
|
+
expect(clearSpy).toHaveBeenCalledWith(undefined)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('should expose instance getter', async () => {
|
|
152
|
+
const service = new CacheService(defaultOptions)
|
|
153
|
+
await service.onApplicationBootstrap()
|
|
154
|
+
expect(service.instance).toBeDefined()
|
|
155
|
+
expect(service.instance.clear).toBeDefined()
|
|
156
|
+
expect(service.instance.close).toBeDefined()
|
|
157
|
+
})
|
|
158
|
+
})
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { sortKeys } from '../src/sort-keys'
|
|
4
|
+
|
|
5
|
+
describe('sortKeys', () => {
|
|
6
|
+
it('should sort object keys alphabetically', () => {
|
|
7
|
+
expect(sortKeys({ c: 1, a: 2, b: 3 })).toEqual({ a: 2, b: 3, c: 1 })
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('should sort nested objects deeply', () => {
|
|
11
|
+
expect(sortKeys({ b: { d: 1, c: 2 }, a: 1 })).toEqual({ a: 1, b: { c: 2, d: 1 } })
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('should sort arrays of objects', () => {
|
|
15
|
+
const input = [
|
|
16
|
+
{ b: 1, a: 2 },
|
|
17
|
+
{ d: 3, c: 4 },
|
|
18
|
+
]
|
|
19
|
+
const expected = [
|
|
20
|
+
{ a: 2, b: 1 },
|
|
21
|
+
{ c: 4, d: 3 },
|
|
22
|
+
]
|
|
23
|
+
expect(sortKeys(input)).toEqual(expected)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('should handle nested arrays', () => {
|
|
27
|
+
const input = { items: [{ z: 1, a: 2 }] }
|
|
28
|
+
expect(sortKeys(input)).toEqual({ items: [{ a: 2, z: 1 }] })
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('should handle nested arrays of arrays', () => {
|
|
32
|
+
const input = { data: [[{ b: 1, a: 2 }]] }
|
|
33
|
+
expect(sortKeys(input)).toEqual({ data: [[{ a: 2, b: 1 }]] })
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('should preserve non-object values in arrays', () => {
|
|
37
|
+
const input = { items: [1, 'string', true, null] }
|
|
38
|
+
expect(sortKeys(input)).toEqual({ items: [1, 'string', true, null] })
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('should handle empty objects', () => {
|
|
42
|
+
expect(sortKeys({})).toEqual({})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('should handle empty arrays', () => {
|
|
46
|
+
expect(sortKeys([])).toEqual([])
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('should handle deeply nested structures', () => {
|
|
50
|
+
const input = { c: { b: { a: { z: 1, y: 2 } } } }
|
|
51
|
+
expect(sortKeys(input)).toEqual({ c: { b: { a: { y: 2, z: 1 } } } })
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('should not modify non-plain objects', () => {
|
|
55
|
+
const date = new Date()
|
|
56
|
+
const input = { b: date, a: 1 }
|
|
57
|
+
const result = sortKeys(input) as Record<string, unknown>
|
|
58
|
+
expect(result.a).toBe(1)
|
|
59
|
+
expect(result.b).toBe(date)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('should handle circular references without infinite loop', () => {
|
|
63
|
+
const obj: Record<string, unknown> = { a: 1 }
|
|
64
|
+
obj.self = obj
|
|
65
|
+
const result = sortKeys(obj)
|
|
66
|
+
expect(result).toBeDefined()
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('should produce deterministic output regardless of input order', () => {
|
|
70
|
+
const a = sortKeys({ z: 1, a: 2, m: 3 })
|
|
71
|
+
const b = sortKeys({ a: 2, m: 3, z: 1 })
|
|
72
|
+
expect(JSON.stringify(a)).toBe(JSON.stringify(b))
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('should handle top-level array input', () => {
|
|
76
|
+
const input = [{ b: 1, a: 2 }] as Record<string, unknown>[]
|
|
77
|
+
const result = sortKeys(input)
|
|
78
|
+
expect(result).toEqual([{ a: 2, b: 1 }])
|
|
79
|
+
})
|
|
80
|
+
})
|
package/tsconfig.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"include": ["src"],
|
|
3
3
|
"compilerOptions": {
|
|
4
|
-
"target": "
|
|
5
|
-
"lib": ["
|
|
4
|
+
"target": "ES2022",
|
|
5
|
+
"lib": ["ES2022"],
|
|
6
6
|
"types": ["node"],
|
|
7
7
|
"module": "Preserve",
|
|
8
8
|
"moduleResolution": "bundler",
|
|
@@ -15,9 +15,9 @@
|
|
|
15
15
|
"declaration": true,
|
|
16
16
|
"declarationMap": true,
|
|
17
17
|
"esModuleInterop": true,
|
|
18
|
+
"experimentalDecorators": true,
|
|
18
19
|
"exactOptionalPropertyTypes": true,
|
|
19
20
|
"forceConsistentCasingInFileNames": true,
|
|
20
|
-
"importHelpers": true,
|
|
21
21
|
"isolatedModules": true,
|
|
22
22
|
"noEmitOnError": true,
|
|
23
23
|
"noFallthroughCasesInSwitch": true,
|