Skip to content

Commit

Permalink
Merge pull request #125 from ryoppippi/feature/refactor-cache
Browse files Browse the repository at this point in the history
refactor(cache): convert cache operations to a class
  • Loading branch information
ryoppippi authored Jun 17, 2024
2 parents f459bb9 + a9aa3de commit b44721b
Show file tree
Hide file tree
Showing 3 changed files with 99 additions and 155 deletions.
197 changes: 74 additions & 123 deletions packages/unplugin-typia/src/core/cache.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { existsSync } from 'node:fs';
import { access, constants, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { accessSync, constants, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { tmpdir } from 'node:os';
import process from 'node:process';
Expand All @@ -10,154 +9,106 @@ import { wrap } from './types.js';
import type { ResolvedOptions } from './options.js';
import { isBun } from './utils.js';

type ResolvedCacheOptions = ResolvedOptions['cache'];
const typiaVersion: Readonly<string> | undefined = await readPackageJSON('typia').then(pkg => pkg.version);

let cacheDir: string | null = null;
let typiaVersion: string | undefined;
type ResolvedCacheOptions = ResolvedOptions['cache'];

/**
* Get cache
* @param id
* @param source
* @param option
* Cache class
*
* @caution: CacheOptions.enable is ignored
*/
export async function getCache(
id: ID,
source: Source,
option: ResolvedCacheOptions,
): Promise<Data | null> {
if (!option.enable) {
return null;
export class Cache {
#data: Data | undefined;
#hashKey: CacheKey;
#hashPath: CachePath;

constructor(id: ID, source: Source, options: ResolvedCacheOptions) {
this.#hashKey = this.getHashKey(id, source);
this.#hashPath = wrap<CachePath>(join(options.base, this.#hashKey));
this.#data = this.getCache();
}
await prepareCacheDir(option);

const key = getKey(id, source);
const path = getCachePath(key, option);

let data: string | null = null;
if (isBun()) {
const file = Bun.file(path);
if (!(await file.exists())) {
return null;
}
data = await file.text();
}
else {
if (!(existsSync(path))) {
return null;
}
data = await readFile(path, { encoding: 'utf8' });
[Symbol.dispose]() {
this.setCache();
}

const hashComment = await getHashComment(key);

/* if data does not end with hashComment, the cache is invalid */
if (!data.endsWith(hashComment)) {
return null;
/**
* Get cache data
*/
get data() {
return this.#data;
}

return wrap<Data>(data);
}

/**
* Set cache
* @param id
* @param source
* @param data
* @param option
*/
export async function setCache(
id: ID,
source: Source,
data: Data,
option: ResolvedCacheOptions,
): Promise<void> {
if (!option.enable) {
return;
/**
* Set cache data
*/
set data(value: Data | undefined) {
this.#data = value;
}
await prepareCacheDir(option);

const key = getKey(id, source);
const path = getCachePath(key, option);
const hashComment = await getHashComment(key);
private getCache() {
if (!(existsSync(this.#hashPath))) {
return undefined;
}

if (data == null) {
await rm(path);
return;
}
const data = readFileSync(this.#hashPath, { encoding: 'utf8' });

const cache = data + hashComment;
if (isBun()) {
await Bun.write(path, cache, { createPath: true });
}
else {
await writeFile(path, cache, { encoding: 'utf8' });
}
}
/* if data does not end with hashComment, the cache is invalid */
if (!data.endsWith(this.hashComment)) {
return undefined;
}

/**
* Get cache key
* @param id
* @param source
*/
function getKey(id: ID, source: Source): CacheKey {
const h = hash(source);
const filebase = `${basename(dirname(id))}_${basename(id)}`;
return wrap<Data>(data);
}

return wrap<CacheKey>(`${filebase}_${h}`);
}
private setCache() {
const isExist = existsSync(this.#hashPath);
const cacheDir = dirname(this.#hashPath);
if (this.#data == null && isExist) {
rmSync(this.#hashPath);
return;
}

/**
* Get storage
* @param key
* @param option
*/
function getCachePath(
key: CacheKey,
option: ResolvedCacheOptions,
): CachePath {
return wrap<CachePath>(join(option.base, key));
}
if (!existsSync(cacheDir)) {
mkdirSync(cacheDir, { recursive: true });
}

async function prepareCacheDir(option: ResolvedCacheOptions) {
if (cacheDir != null) {
return;
}
const _cacheDir = option.base;
await mkdir(_cacheDir, { recursive: true });
if (!this.isWritable(cacheDir)) {
throw new Error('Cache directory is not writable.');
}

if (!await isWritable(_cacheDir)) {
throw new Error('Cache directory is not writable.');
const cache = this.#data + this.hashComment;
writeFileSync(this.#hashPath, cache, { encoding: 'utf8' });
}

cacheDir = _cacheDir;
}
private getHashKey(id: ID, source: Source): CacheKey {
const h = this.hash(source);
const filebase = `${basename(dirname(id))}_${basename(id)}`;

async function isWritable(filename: string): Promise<boolean> {
try {
await access(filename, constants.W_OK);
return true;
return wrap<CacheKey>(`${filebase}_${h}`);
}
catch {
return false;

private hash(input: string): string {
if (isBun()) {
return Bun.hash(input).toString();
}
return createHash('md5').update(input).digest('hex');
}
}

/**
* Create hash string
* @param input
* @returns The hash string.
*/
function hash(input: string): string {
if (isBun()) {
return Bun.hash(input).toString();
private get hashComment() {
return `/* unplugin-typia-${typiaVersion ?? ''}-${this.#hashKey} */`;
}
return createHash('md5').update(input).digest('hex');
}

async function getHashComment(cachePath: CacheKey) {
typiaVersion = typiaVersion ?? await readPackageJSON('typia').then(pkg => pkg.version);
return `/* unplugin-typia-${typiaVersion ?? ''}-${cachePath} */`;
private isWritable(filename: string): boolean {
try {
accessSync(filename, constants.W_OK);
return true;
}
catch {
return false;
}
}
}

/**
Expand Down
9 changes: 6 additions & 3 deletions packages/unplugin-typia/src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ import MagicString from 'magic-string';
import type { CacheOptions, Options } from './options.js';
import { resolveOptions } from './options.js';
import { transformTypia } from './typia.js';
import { getCache, setCache } from './cache.js';
import { log } from './utils.js';
import type { ID, Source } from './types.js';
import { wrap } from './types.js';
import { Cache } from './cache.js';

const name = `unplugin-typia`;

Expand Down Expand Up @@ -60,7 +60,8 @@ const unpluginFactory: UnpluginFactory<
const id = wrap<ID>(_id);

/** get cache */
let code = await getCache(id, source, cacheOptions);
using cache = cacheOptions.enable ? new Cache(id, source, cacheOptions) : undefined;
let code = cache?.data;

if (showLog) {
if (code != null) {
Expand All @@ -85,7 +86,9 @@ const unpluginFactory: UnpluginFactory<
}

/** save cache */
await setCache(id, source, code, cacheOptions);
if (cache != null) {
cache.data = code;
}

if (showLog) {
log('success', `Cache set: ${id}`);
Expand Down
48 changes: 19 additions & 29 deletions packages/unplugin-typia/test/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@ import { tmpdir } from 'node:os';
import { test } from 'bun:test';
import { assertEquals, assertNotEquals } from '@std/assert';

import * as cache from '../src/core/cache.js';
import { Cache } from '../src/core/cache.js';
import type { Data, FilePath, ID, Source } from '../src/core/types.js';
import { wrap } from '../src/core/types.js';

const tmp = wrap<FilePath>(tmpdir());

function removeComments(data: string | null) {
function removeComments(data: string | undefined) {
if (data == null) {
return data;
}
Expand All @@ -21,8 +21,8 @@ test('return null if cache is not found', async () => {
const option = { enable: true, base: tmp } as const;
const random = Math.random().toString();
const source = wrap<Source>(random);
const data = await cache.getCache(wrap<ID>(random), source, option);
assertEquals(data, null);
using cache = new Cache(wrap<ID>(random), source, option);
assertEquals(cache.data, undefined);
});

test('set and get cache', async () => {
Expand All @@ -33,15 +33,18 @@ test('set and get cache', async () => {
const data = wrap<Data>(`${random};`);

/* set cache */
await cache.setCache(filename, source, data, option);
{
using cache = new Cache(filename, source, option);
cache.data = data;
}

/* get cache */
const cacheData = await cache.getCache(filename, source, option);
using cache = new Cache(filename, source, option);

/* delete js asterisk comments */
const cacheDataStr = removeComments(cacheData);
const cacheDataStr = removeComments(cache.data);

assertEquals(data, cacheDataStr);
assertEquals(cacheDataStr, data);
});

test('set and get null with different id', async () => {
Expand All @@ -52,30 +55,17 @@ test('set and get null with different id', async () => {
const data = wrap<Data>(`${random};`);

/* set cache */
await cache.setCache(filename, source, data, option);
{
using cache = new Cache(filename, source, option);
cache.data = data;
}

/* get cache */
const cacheData = await cache.getCache(`111;${random}` as ID, source, option);
using cache = new Cache(wrap<ID>(`111;${random}`), source, option);

/* delete js asterisk comments */
const cacheDataStr = removeComments(cacheData);

assertEquals(cacheDataStr, null);
assertNotEquals(data, cacheDataStr);
});

test('set and get null with cache disabled', async () => {
const option = { enable: false, base: tmp } as const;
const random = Math.random().toString();
const source = wrap<Source>(random);
const filename = wrap<ID>(`${random}-${random}.json`);
const data = wrap<Data>(`${random};`);

/* set cache */
await cache.setCache(filename, source, data, option);

/* get cache */
const cacheData = await cache.getCache(filename, source, option);
const cacheDataStr = removeComments(cache.data);

assertEquals(cacheData, null);
assertEquals(cacheDataStr, undefined);
assertNotEquals(cacheDataStr, data);
});

0 comments on commit b44721b

Please sign in to comment.