refactor: add effect-ts and refactor codes

This commit is contained in:
d-kimsuon
2025-10-15 23:22:27 +09:00
parent 94cc1c0630
commit 21070d09ff
76 changed files with 7598 additions and 1950 deletions

View File

@@ -1,82 +0,0 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { z } from "zod";
import { claudeCodeViewerCacheDirPath } from "../../service/paths";
const saveSchema = z.array(z.tuple([z.string(), z.unknown()]));
export class FileCacheStorage<const T> {
private storage = new Map<string, T>();
private constructor(private readonly key: string) {}
public static load<const LoadSchema>(
key: string,
schema: z.ZodType<LoadSchema>,
) {
const instance = new FileCacheStorage<LoadSchema>(key);
if (!existsSync(claudeCodeViewerCacheDirPath)) {
mkdirSync(claudeCodeViewerCacheDirPath, { recursive: true });
}
if (!existsSync(instance.cacheFilePath)) {
writeFileSync(instance.cacheFilePath, "[]");
} else {
const content = readFileSync(instance.cacheFilePath, "utf-8");
const parsed = saveSchema.safeParse(JSON.parse(content));
if (!parsed.success) {
writeFileSync(instance.cacheFilePath, "[]");
} else {
for (const [key, value] of parsed.data) {
const parsedValue = schema.safeParse(value);
if (!parsedValue.success) {
continue;
}
instance.storage.set(key, parsedValue.data);
}
}
}
return instance;
}
private get cacheFilePath() {
return resolve(claudeCodeViewerCacheDirPath, `${this.key}.json`);
}
private asSaveFormat() {
return JSON.stringify(Array.from(this.storage.entries()));
}
private async syncToFile() {
await writeFile(this.cacheFilePath, this.asSaveFormat());
}
public get(key: string) {
return this.storage.get(key);
}
public save(key: string, value: T) {
const previous = this.asSaveFormat();
this.storage.set(key, value);
if (previous === this.asSaveFormat()) {
return;
}
void this.syncToFile();
}
public invalidate(key: string) {
if (!this.storage.has(key)) {
return;
}
this.storage.delete(key);
void this.syncToFile();
}
}

View File

@@ -0,0 +1,64 @@
import { resolve } from "node:path";
import { FileSystem } from "@effect/platform";
import { Context, Effect, Layer } from "effect";
import { z } from "zod";
import { claudeCodeViewerCacheDirPath } from "../../../service/paths";
const saveSchema = z.array(z.tuple([z.string(), z.unknown()]));
const getCacheFilePath = (key: string) =>
resolve(claudeCodeViewerCacheDirPath, `${key}.json`);
const load = (key: string) => {
const cacheFilePath = getCacheFilePath(key);
return Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
if (!(yield* fs.exists(claudeCodeViewerCacheDirPath))) {
yield* fs.makeDirectory(claudeCodeViewerCacheDirPath, {
recursive: true,
});
}
if (!(yield* fs.exists(cacheFilePath))) {
yield* fs.writeFileString(cacheFilePath, "[]");
} else {
const content = yield* fs.readFileString(cacheFilePath);
const parsed = saveSchema.safeParse(JSON.parse(content));
if (!parsed.success) {
yield* fs.writeFileString(cacheFilePath, "[]");
} else {
parsed.data;
return parsed.data;
}
}
return [];
});
};
const save = (key: string, entries: readonly [string, unknown][]) => {
const cacheFilePath = getCacheFilePath(key);
return Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
yield* fs.writeFileString(cacheFilePath, JSON.stringify(entries));
});
};
export class PersistentService extends Context.Tag("PersistentService")<
PersistentService,
{
readonly load: typeof load;
readonly save: typeof save;
}
>() {
static Live = Layer.succeed(this, {
load,
save,
});
}
export type IPersistentService = Context.Tag.Service<PersistentService>;

View File

@@ -0,0 +1,516 @@
import { FileSystem } from "@effect/platform";
import { Effect, Layer, Ref } from "effect";
import { z } from "zod";
import { FileCacheStorage, makeFileCacheStorageLayer } from "./index";
import { PersistentService } from "./PersistantService";
// Schema for testing
const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
});
type User = z.infer<typeof UserSchema>;
const FileSystemMock = FileSystem.layerNoop({});
describe("FileCacheStorage", () => {
describe("basic operations", () => {
it("can save and retrieve data with set and get", async () => {
// PersistentService mock (empty data)
const PersistentServiceMock = Layer.succeed(PersistentService, {
load: () => Effect.succeed([]),
save: () => Effect.void,
});
const program = Effect.gen(function* () {
const cache = yield* FileCacheStorage<User>();
// Save data
yield* cache.set("user-1", {
id: "user-1",
name: "Alice",
email: "alice@example.com",
});
// Retrieve data
const user = yield* cache.get("user-1");
return user;
});
const result = await Effect.runPromise(
program.pipe(
Effect.provide(
makeFileCacheStorageLayer("test-users", UserSchema).pipe(
Layer.provide(PersistentServiceMock),
Layer.provide(FileSystemMock),
),
),
),
);
expect(result).toEqual({
id: "user-1",
name: "Alice",
email: "alice@example.com",
});
});
it("returns undefined when retrieving non-existent key", async () => {
const PersistentServiceMock = Layer.succeed(PersistentService, {
load: () => Effect.succeed([]),
save: () => Effect.void,
});
const program = Effect.gen(function* () {
const cache = yield* FileCacheStorage<User>();
return yield* cache.get("non-existent");
});
const result = await Effect.runPromise(
program.pipe(
Effect.provide(
makeFileCacheStorageLayer("test-users", UserSchema).pipe(
Layer.provide(PersistentServiceMock),
Layer.provide(FileSystemMock),
),
),
),
);
expect(result).toBeUndefined();
});
it("can delete data with invalidate", async () => {
const PersistentServiceMock = Layer.succeed(PersistentService, {
load: () => Effect.succeed([]),
save: () => Effect.void,
});
const program = Effect.gen(function* () {
const cache = yield* FileCacheStorage<User>();
// Save data
yield* cache.set("user-1", {
id: "user-1",
name: "Alice",
email: "alice@example.com",
});
// Delete data
yield* cache.invalidate("user-1");
// Returns undefined after deletion
return yield* cache.get("user-1");
});
const result = await Effect.runPromise(
program.pipe(
Effect.provide(
makeFileCacheStorageLayer("test-users", UserSchema).pipe(
Layer.provide(PersistentServiceMock),
Layer.provide(FileSystemMock),
),
),
),
);
expect(result).toBeUndefined();
});
it("getAll ですべてのデータを取得できる", async () => {
const PersistentServiceMock = Layer.succeed(PersistentService, {
load: () => Effect.succeed([]),
save: () => Effect.void,
});
const program = Effect.gen(function* () {
const cache = yield* FileCacheStorage<User>();
// 複数のデータを保存
yield* cache.set("user-1", {
id: "user-1",
name: "Alice",
email: "alice@example.com",
});
yield* cache.set("user-2", {
id: "user-2",
name: "Bob",
email: "bob@example.com",
});
// すべてのデータを取得
return yield* cache.getAll();
});
const result = await Effect.runPromise(
program.pipe(
Effect.provide(
makeFileCacheStorageLayer("test-users", UserSchema).pipe(
Layer.provide(PersistentServiceMock),
Layer.provide(FileSystemMock),
),
),
),
);
expect(result.size).toBe(2);
expect(result.get("user-1")).toEqual({
id: "user-1",
name: "Alice",
email: "alice@example.com",
});
expect(result.get("user-2")).toEqual({
id: "user-2",
name: "Bob",
email: "bob@example.com",
});
});
});
describe("永続化データの読み込み", () => {
it("初期化時に永続化データを読み込む", async () => {
// 永続化データを返すモック
const PersistentServiceMock = Layer.succeed(PersistentService, {
load: () =>
Effect.succeed([
[
"user-1",
{
id: "user-1",
name: "Alice",
email: "alice@example.com",
},
],
[
"user-2",
{
id: "user-2",
name: "Bob",
email: "bob@example.com",
},
],
] as const),
save: () => Effect.void,
});
const program = Effect.gen(function* () {
const cache = yield* FileCacheStorage<User>();
return yield* cache.getAll();
});
const result = await Effect.runPromise(
program.pipe(
Effect.provide(
makeFileCacheStorageLayer("test-users", UserSchema).pipe(
Layer.provide(PersistentServiceMock),
Layer.provide(FileSystemMock),
),
),
),
);
expect(result.size).toBe(2);
expect(result.get("user-1")?.name).toBe("Alice");
expect(result.get("user-2")?.name).toBe("Bob");
});
it("スキーマバリデーションに失敗したデータは無視される", async () => {
// 不正なデータを含む永続化データ
const PersistentServiceMock = Layer.succeed(PersistentService, {
load: () =>
Effect.succeed([
[
"user-1",
{
id: "user-1",
name: "Alice",
email: "alice@example.com",
},
],
[
"user-invalid",
{
id: "invalid",
name: "Invalid",
// email が無い(バリデーションエラー)
},
],
[
"user-2",
{
id: "user-2",
name: "Bob",
email: "invalid-email", // 不正なメールアドレス
},
],
] as const),
save: () => Effect.void,
});
const program = Effect.gen(function* () {
const cache = yield* FileCacheStorage<User>();
return yield* cache.getAll();
});
const result = await Effect.runPromise(
program.pipe(
Effect.provide(
makeFileCacheStorageLayer("test-users", UserSchema).pipe(
Layer.provide(PersistentServiceMock),
Layer.provide(FileSystemMock),
),
),
),
);
// 有効なデータのみ読み込まれる
expect(result.size).toBe(1);
expect(result.get("user-1")?.name).toBe("Alice");
expect(result.get("user-invalid")).toBeUndefined();
expect(result.get("user-2")).toBeUndefined();
});
});
describe("永続化への同期", () => {
it("set でデータを保存すると save が呼ばれる", async () => {
const saveCallsRef = await Effect.runPromise(Ref.make<number>(0));
const PersistentServiceMock = Layer.succeed(PersistentService, {
load: () => Effect.succeed([]),
save: () =>
Effect.gen(function* () {
yield* Ref.update(saveCallsRef, (n) => n + 1);
}),
});
const program = Effect.gen(function* () {
const cache = yield* FileCacheStorage<User>();
yield* cache.set("user-1", {
id: "user-1",
name: "Alice",
email: "alice@example.com",
});
// バックグラウンド実行を待つために少し待機
yield* Effect.sleep("10 millis");
});
await Effect.runPromise(
program.pipe(
Effect.provide(
makeFileCacheStorageLayer("test-users", UserSchema).pipe(
Layer.provide(PersistentServiceMock),
Layer.provide(FileSystemMock),
),
),
),
);
const saveCalls = await Effect.runPromise(Ref.get(saveCallsRef));
expect(saveCalls).toBeGreaterThan(0);
});
it("同じ値を set しても save は呼ばれない(差分検出)", async () => {
const saveCallsRef = await Effect.runPromise(Ref.make<number>(0));
const PersistentServiceMock = Layer.succeed(PersistentService, {
load: () =>
Effect.succeed([
[
"user-1",
{
id: "user-1",
name: "Alice",
email: "alice@example.com",
},
],
] as const),
save: () =>
Effect.gen(function* () {
yield* Ref.update(saveCallsRef, (n) => n + 1);
}),
});
const program = Effect.gen(function* () {
const cache = yield* FileCacheStorage<User>();
// 既に存在する同じ値を set
yield* cache.set("user-1", {
id: "user-1",
name: "Alice",
email: "alice@example.com",
});
// バックグラウンド実行を待つために少し待機
yield* Effect.sleep("10 millis");
});
await Effect.runPromise(
program.pipe(
Effect.provide(
makeFileCacheStorageLayer("test-users", UserSchema).pipe(
Layer.provide(PersistentServiceMock),
Layer.provide(FileSystemMock),
),
),
),
);
const saveCalls = await Effect.runPromise(Ref.get(saveCallsRef));
// 差分がないので save は呼ばれない
expect(saveCalls).toBe(0);
});
it("invalidate でデータを削除すると save が呼ばれる", async () => {
const saveCallsRef = await Effect.runPromise(Ref.make<number>(0));
const PersistentServiceMock = Layer.succeed(PersistentService, {
load: () =>
Effect.succeed([
[
"user-1",
{
id: "user-1",
name: "Alice",
email: "alice@example.com",
},
],
] as const),
save: () =>
Effect.gen(function* () {
yield* Ref.update(saveCallsRef, (n) => n + 1);
}),
});
const program = Effect.gen(function* () {
const cache = yield* FileCacheStorage<User>();
yield* cache.invalidate("user-1");
// バックグラウンド実行を待つために少し待機
yield* Effect.sleep("10 millis");
});
await Effect.runPromise(
program.pipe(
Effect.provide(
makeFileCacheStorageLayer("test-users", UserSchema).pipe(
Layer.provide(PersistentServiceMock),
Layer.provide(FileSystemMock),
),
),
),
);
const saveCalls = await Effect.runPromise(Ref.get(saveCallsRef));
expect(saveCalls).toBeGreaterThan(0);
});
it("存在しないキーを invalidate しても save は呼ばれない", async () => {
const saveCallsRef = await Effect.runPromise(Ref.make<number>(0));
const PersistentServiceMock = Layer.succeed(PersistentService, {
load: () => Effect.succeed([]),
save: () =>
Effect.gen(function* () {
yield* Ref.update(saveCallsRef, (n) => n + 1);
}),
});
const program = Effect.gen(function* () {
const cache = yield* FileCacheStorage<User>();
// 存在しないキーを invalidate
yield* cache.invalidate("non-existent");
// バックグラウンド実行を待つために少し待機
yield* Effect.sleep("10 millis");
});
await Effect.runPromise(
program.pipe(
Effect.provide(
makeFileCacheStorageLayer("test-users", UserSchema).pipe(
Layer.provide(PersistentServiceMock),
Layer.provide(FileSystemMock),
),
),
),
);
const saveCalls = await Effect.runPromise(Ref.get(saveCallsRef));
// 存在しないキーなので save は呼ばれない
expect(saveCalls).toBe(0);
});
});
describe("複雑なシナリオ", () => {
it("複数の操作を順次実行できる", async () => {
const PersistentServiceMock = Layer.succeed(PersistentService, {
load: () =>
Effect.succeed([
[
"user-1",
{
id: "user-1",
name: "Alice",
email: "alice@example.com",
},
],
] as const),
save: () => Effect.void,
});
const program = Effect.gen(function* () {
const cache = yield* FileCacheStorage<User>();
// 初期データの確認
const initial = yield* cache.getAll();
expect(initial.size).toBe(1);
// 新しいユーザーを追加
yield* cache.set("user-2", {
id: "user-2",
name: "Bob",
email: "bob@example.com",
});
// 既存のユーザーを更新
yield* cache.set("user-1", {
id: "user-1",
name: "Alice Updated",
email: "alice.updated@example.com",
});
// すべてのデータを取得
const afterUpdate = yield* cache.getAll();
expect(afterUpdate.size).toBe(2);
expect(afterUpdate.get("user-1")?.name).toBe("Alice Updated");
expect(afterUpdate.get("user-2")?.name).toBe("Bob");
// ユーザーを削除
yield* cache.invalidate("user-1");
// 削除後の確認
const afterDelete = yield* cache.getAll();
expect(afterDelete.size).toBe(1);
expect(afterDelete.get("user-1")).toBeUndefined();
expect(afterDelete.get("user-2")?.name).toBe("Bob");
});
await Effect.runPromise(
program.pipe(
Effect.provide(
makeFileCacheStorageLayer("test-users", UserSchema).pipe(
Layer.provide(PersistentServiceMock),
Layer.provide(FileSystemMock),
),
),
),
);
});
});
});

View File

@@ -0,0 +1,94 @@
import type { FileSystem } from "@effect/platform";
import { Context, Effect, Layer, Ref, Runtime } from "effect";
import type { z } from "zod";
import { PersistentService } from "./PersistantService";
export interface FileCacheStorageService<T> {
readonly get: (key: string) => Effect.Effect<T | undefined>;
readonly set: (key: string, value: T) => Effect.Effect<void>;
readonly invalidate: (key: string) => Effect.Effect<void>;
readonly getAll: () => Effect.Effect<Map<string, T>>;
}
export const FileCacheStorage = <T>() =>
Context.GenericTag<FileCacheStorageService<T>>("FileCacheStorage");
export const makeFileCacheStorageLayer = <T>(
storageKey: string,
schema: z.ZodType<T>,
) =>
Layer.effect(
FileCacheStorage<T>(),
Effect.gen(function* () {
const persistentService = yield* PersistentService;
const runtime = yield* Effect.runtime<FileSystem.FileSystem>();
const storageRef = yield* Effect.gen(function* () {
const persistedData = yield* persistentService.load(storageKey);
const initialMap = new Map<string, T>();
for (const [key, value] of persistedData) {
const parsed = schema.safeParse(value);
if (parsed.success) {
initialMap.set(key, parsed.data);
}
}
return yield* Ref.make(initialMap);
});
const syncToFile = (entries: readonly [string, T][]) => {
Runtime.runFork(runtime)(persistentService.save(storageKey, entries));
};
return {
get: (key: string) =>
Effect.gen(function* () {
const storage = yield* Ref.get(storageRef);
return storage.get(key);
}),
set: (key: string, value: T) =>
Effect.gen(function* () {
const before = yield* Ref.get(storageRef);
const beforeString = JSON.stringify(Array.from(before.entries()));
yield* Ref.update(storageRef, (map) => {
map.set(key, value);
return map;
});
const after = yield* Ref.get(storageRef);
const afterString = JSON.stringify(Array.from(after.entries()));
if (beforeString !== afterString) {
syncToFile(Array.from(after.entries()));
}
}),
invalidate: (key: string) =>
Effect.gen(function* () {
const before = yield* Ref.get(storageRef);
if (!before.has(key)) {
return;
}
yield* Ref.update(storageRef, (map) => {
map.delete(key);
return map;
});
const after = yield* Ref.get(storageRef);
syncToFile(Array.from(after.entries()));
}),
getAll: () =>
Effect.gen(function* () {
const storage = yield* Ref.get(storageRef);
return new Map(storage);
}),
};
}),
);

View File

@@ -1,19 +0,0 @@
export class InMemoryCacheStorage<const T> {
private storage = new Map<string, T>();
public get(key: string) {
return this.storage.get(key);
}
public save(key: string, value: T) {
this.storage.set(key, value);
}
public invalidate(key: string) {
if (!this.storage.has(key)) {
return;
}
this.storage.delete(key);
}
}