mirror of
https://github.com/samiyev/puaros.git
synced 2025-12-28 07:16:53 +05:00
feat(ipuaro): implement Redis storage module (v0.2.0)
- Add RedisClient with connection management and AOF config - Add RedisStorage implementing full IStorage interface - Add Redis key schema for project and session data - Add generateProjectName() utility - Add 68 unit tests for Redis module (159 total) - Update ESLint: no-unnecessary-type-parameters as warn
This commit is contained in:
@@ -13,5 +13,8 @@ export * from "./application/index.js"
|
||||
// Shared exports
|
||||
export * from "./shared/index.js"
|
||||
|
||||
// Infrastructure exports
|
||||
export * from "./infrastructure/index.js"
|
||||
|
||||
// Version
|
||||
export const VERSION = "0.1.0"
|
||||
export const VERSION = "0.2.0"
|
||||
|
||||
2
packages/ipuaro/src/infrastructure/index.ts
Normal file
2
packages/ipuaro/src/infrastructure/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Infrastructure layer exports
|
||||
export * from "./storage/index.js"
|
||||
119
packages/ipuaro/src/infrastructure/storage/RedisClient.ts
Normal file
119
packages/ipuaro/src/infrastructure/storage/RedisClient.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { Redis } from "ioredis"
|
||||
import type { RedisConfig } from "../../shared/constants/config.js"
|
||||
import { IpuaroError } from "../../shared/errors/IpuaroError.js"
|
||||
|
||||
/**
|
||||
* Redis client wrapper with connection management.
|
||||
* Handles connection lifecycle and AOF configuration.
|
||||
*/
|
||||
export class RedisClient {
|
||||
private client: Redis | null = null
|
||||
private readonly config: RedisConfig
|
||||
private connected = false
|
||||
|
||||
constructor(config: RedisConfig) {
|
||||
this.config = config
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to Redis server.
|
||||
* Configures AOF persistence on successful connection.
|
||||
*/
|
||||
async connect(): Promise<void> {
|
||||
if (this.connected && this.client) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
this.client = new Redis({
|
||||
host: this.config.host,
|
||||
port: this.config.port,
|
||||
db: this.config.db,
|
||||
password: this.config.password,
|
||||
keyPrefix: this.config.keyPrefix,
|
||||
lazyConnect: true,
|
||||
retryStrategy: (times: number): number | null => {
|
||||
if (times > 3) {
|
||||
return null
|
||||
}
|
||||
return Math.min(times * 200, 1000)
|
||||
},
|
||||
maxRetriesPerRequest: 3,
|
||||
enableReadyCheck: true,
|
||||
})
|
||||
|
||||
await this.client.connect()
|
||||
await this.configureAOF()
|
||||
this.connected = true
|
||||
} catch (error) {
|
||||
this.connected = false
|
||||
this.client = null
|
||||
const message = error instanceof Error ? error.message : "Unknown error"
|
||||
throw IpuaroError.redis(`Failed to connect to Redis: ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from Redis server.
|
||||
*/
|
||||
async disconnect(): Promise<void> {
|
||||
if (this.client) {
|
||||
await this.client.quit()
|
||||
this.client = null
|
||||
this.connected = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if connected to Redis.
|
||||
*/
|
||||
isConnected(): boolean {
|
||||
return this.connected && this.client !== null && this.client.status === "ready"
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the underlying Redis client.
|
||||
* @throws IpuaroError if not connected
|
||||
*/
|
||||
getClient(): Redis {
|
||||
if (!this.client || !this.connected) {
|
||||
throw IpuaroError.redis("Redis client is not connected")
|
||||
}
|
||||
return this.client
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a health check ping.
|
||||
*/
|
||||
async ping(): Promise<boolean> {
|
||||
if (!this.client) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
const result = await this.client.ping()
|
||||
return result === "PONG"
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure AOF (Append Only File) persistence.
|
||||
* AOF provides better durability by logging every write operation.
|
||||
*/
|
||||
private async configureAOF(): Promise<void> {
|
||||
if (!this.client) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await this.client.config("SET", "appendonly", "yes")
|
||||
await this.client.config("SET", "appendfsync", "everysec")
|
||||
} catch {
|
||||
/*
|
||||
* AOF config may fail if Redis doesn't allow CONFIG SET.
|
||||
* This is non-fatal - persistence will still work with default settings.
|
||||
*/
|
||||
}
|
||||
}
|
||||
}
|
||||
236
packages/ipuaro/src/infrastructure/storage/RedisStorage.ts
Normal file
236
packages/ipuaro/src/infrastructure/storage/RedisStorage.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import type { DepsGraph, IStorage, SymbolIndex } from "../../domain/services/IStorage.js"
|
||||
import type { FileAST } from "../../domain/value-objects/FileAST.js"
|
||||
import type { FileData } from "../../domain/value-objects/FileData.js"
|
||||
import type { FileMeta } from "../../domain/value-objects/FileMeta.js"
|
||||
import { IpuaroError } from "../../shared/errors/IpuaroError.js"
|
||||
import { RedisClient } from "./RedisClient.js"
|
||||
import { IndexFields, ProjectKeys } from "./schema.js"
|
||||
|
||||
/**
|
||||
* Redis implementation of IStorage.
|
||||
* Stores project data (files, AST, meta, indexes) in Redis hashes.
|
||||
*/
|
||||
export class RedisStorage implements IStorage {
|
||||
private readonly client: RedisClient
|
||||
private readonly projectName: string
|
||||
|
||||
constructor(client: RedisClient, projectName: string) {
|
||||
this.client = client
|
||||
this.projectName = projectName
|
||||
}
|
||||
|
||||
async getFile(path: string): Promise<FileData | null> {
|
||||
const redis = this.getRedis()
|
||||
const data = await redis.hget(ProjectKeys.files(this.projectName), path)
|
||||
if (!data) {
|
||||
return null
|
||||
}
|
||||
return this.parseJSON<FileData>(data, "FileData")
|
||||
}
|
||||
|
||||
async setFile(path: string, data: FileData): Promise<void> {
|
||||
const redis = this.getRedis()
|
||||
await redis.hset(ProjectKeys.files(this.projectName), path, JSON.stringify(data))
|
||||
}
|
||||
|
||||
async deleteFile(path: string): Promise<void> {
|
||||
const redis = this.getRedis()
|
||||
await redis.hdel(ProjectKeys.files(this.projectName), path)
|
||||
}
|
||||
|
||||
async getAllFiles(): Promise<Map<string, FileData>> {
|
||||
const redis = this.getRedis()
|
||||
const data = await redis.hgetall(ProjectKeys.files(this.projectName))
|
||||
const result = new Map<string, FileData>()
|
||||
|
||||
for (const [path, value] of Object.entries(data)) {
|
||||
const parsed = this.parseJSON<FileData>(value, "FileData")
|
||||
if (parsed) {
|
||||
result.set(path, parsed)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async getFileCount(): Promise<number> {
|
||||
const redis = this.getRedis()
|
||||
return redis.hlen(ProjectKeys.files(this.projectName))
|
||||
}
|
||||
|
||||
async getAST(path: string): Promise<FileAST | null> {
|
||||
const redis = this.getRedis()
|
||||
const data = await redis.hget(ProjectKeys.ast(this.projectName), path)
|
||||
if (!data) {
|
||||
return null
|
||||
}
|
||||
return this.parseJSON<FileAST>(data, "FileAST")
|
||||
}
|
||||
|
||||
async setAST(path: string, ast: FileAST): Promise<void> {
|
||||
const redis = this.getRedis()
|
||||
await redis.hset(ProjectKeys.ast(this.projectName), path, JSON.stringify(ast))
|
||||
}
|
||||
|
||||
async deleteAST(path: string): Promise<void> {
|
||||
const redis = this.getRedis()
|
||||
await redis.hdel(ProjectKeys.ast(this.projectName), path)
|
||||
}
|
||||
|
||||
async getAllASTs(): Promise<Map<string, FileAST>> {
|
||||
const redis = this.getRedis()
|
||||
const data = await redis.hgetall(ProjectKeys.ast(this.projectName))
|
||||
const result = new Map<string, FileAST>()
|
||||
|
||||
for (const [path, value] of Object.entries(data)) {
|
||||
const parsed = this.parseJSON<FileAST>(value, "FileAST")
|
||||
if (parsed) {
|
||||
result.set(path, parsed)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async getMeta(path: string): Promise<FileMeta | null> {
|
||||
const redis = this.getRedis()
|
||||
const data = await redis.hget(ProjectKeys.meta(this.projectName), path)
|
||||
if (!data) {
|
||||
return null
|
||||
}
|
||||
return this.parseJSON<FileMeta>(data, "FileMeta")
|
||||
}
|
||||
|
||||
async setMeta(path: string, meta: FileMeta): Promise<void> {
|
||||
const redis = this.getRedis()
|
||||
await redis.hset(ProjectKeys.meta(this.projectName), path, JSON.stringify(meta))
|
||||
}
|
||||
|
||||
async deleteMeta(path: string): Promise<void> {
|
||||
const redis = this.getRedis()
|
||||
await redis.hdel(ProjectKeys.meta(this.projectName), path)
|
||||
}
|
||||
|
||||
async getAllMetas(): Promise<Map<string, FileMeta>> {
|
||||
const redis = this.getRedis()
|
||||
const data = await redis.hgetall(ProjectKeys.meta(this.projectName))
|
||||
const result = new Map<string, FileMeta>()
|
||||
|
||||
for (const [path, value] of Object.entries(data)) {
|
||||
const parsed = this.parseJSON<FileMeta>(value, "FileMeta")
|
||||
if (parsed) {
|
||||
result.set(path, parsed)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async getSymbolIndex(): Promise<SymbolIndex> {
|
||||
const redis = this.getRedis()
|
||||
const data = await redis.hget(ProjectKeys.indexes(this.projectName), IndexFields.symbols)
|
||||
if (!data) {
|
||||
return new Map()
|
||||
}
|
||||
|
||||
const parsed = this.parseJSON<[string, unknown[]][]>(data, "SymbolIndex")
|
||||
if (!parsed) {
|
||||
return new Map()
|
||||
}
|
||||
|
||||
return new Map(parsed) as SymbolIndex
|
||||
}
|
||||
|
||||
async setSymbolIndex(index: SymbolIndex): Promise<void> {
|
||||
const redis = this.getRedis()
|
||||
const serialized = JSON.stringify([...index.entries()])
|
||||
await redis.hset(ProjectKeys.indexes(this.projectName), IndexFields.symbols, serialized)
|
||||
}
|
||||
|
||||
async getDepsGraph(): Promise<DepsGraph> {
|
||||
const redis = this.getRedis()
|
||||
const data = await redis.hget(ProjectKeys.indexes(this.projectName), IndexFields.depsGraph)
|
||||
if (!data) {
|
||||
return {
|
||||
imports: new Map(),
|
||||
importedBy: new Map(),
|
||||
}
|
||||
}
|
||||
|
||||
const parsed = this.parseJSON<{
|
||||
imports: [string, string[]][]
|
||||
importedBy: [string, string[]][]
|
||||
}>(data, "DepsGraph")
|
||||
|
||||
if (!parsed) {
|
||||
return {
|
||||
imports: new Map(),
|
||||
importedBy: new Map(),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
imports: new Map(parsed.imports),
|
||||
importedBy: new Map(parsed.importedBy),
|
||||
}
|
||||
}
|
||||
|
||||
async setDepsGraph(graph: DepsGraph): Promise<void> {
|
||||
const redis = this.getRedis()
|
||||
const serialized = JSON.stringify({
|
||||
imports: [...graph.imports.entries()],
|
||||
importedBy: [...graph.importedBy.entries()],
|
||||
})
|
||||
await redis.hset(ProjectKeys.indexes(this.projectName), IndexFields.depsGraph, serialized)
|
||||
}
|
||||
|
||||
async getProjectConfig(key: string): Promise<unknown> {
|
||||
const redis = this.getRedis()
|
||||
const data = await redis.hget(ProjectKeys.config(this.projectName), key)
|
||||
if (!data) {
|
||||
return null
|
||||
}
|
||||
return this.parseJSON<unknown>(data, "ProjectConfig")
|
||||
}
|
||||
|
||||
async setProjectConfig(key: string, value: unknown): Promise<void> {
|
||||
const redis = this.getRedis()
|
||||
await redis.hset(ProjectKeys.config(this.projectName), key, JSON.stringify(value))
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
await this.client.connect()
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
await this.client.disconnect()
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.client.isConnected()
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
const redis = this.getRedis()
|
||||
await Promise.all([
|
||||
redis.del(ProjectKeys.files(this.projectName)),
|
||||
redis.del(ProjectKeys.ast(this.projectName)),
|
||||
redis.del(ProjectKeys.meta(this.projectName)),
|
||||
redis.del(ProjectKeys.indexes(this.projectName)),
|
||||
redis.del(ProjectKeys.config(this.projectName)),
|
||||
])
|
||||
}
|
||||
|
||||
private getRedis(): ReturnType<RedisClient["getClient"]> {
|
||||
return this.client.getClient()
|
||||
}
|
||||
|
||||
private parseJSON<T>(data: string, type: string): T | null {
|
||||
try {
|
||||
return JSON.parse(data) as T
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error"
|
||||
throw IpuaroError.parse(`Failed to parse ${type}: ${message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
10
packages/ipuaro/src/infrastructure/storage/index.ts
Normal file
10
packages/ipuaro/src/infrastructure/storage/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// Storage module exports
|
||||
export { RedisClient } from "./RedisClient.js"
|
||||
export { RedisStorage } from "./RedisStorage.js"
|
||||
export {
|
||||
ProjectKeys,
|
||||
SessionKeys,
|
||||
IndexFields,
|
||||
SessionFields,
|
||||
generateProjectName,
|
||||
} from "./schema.js"
|
||||
95
packages/ipuaro/src/infrastructure/storage/schema.ts
Normal file
95
packages/ipuaro/src/infrastructure/storage/schema.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Redis key schema for ipuaro data storage.
|
||||
*
|
||||
* Key structure:
|
||||
* - project:{name}:files # Hash<path, FileData>
|
||||
* - project:{name}:ast # Hash<path, FileAST>
|
||||
* - project:{name}:meta # Hash<path, FileMeta>
|
||||
* - project:{name}:indexes # Hash<name, JSON> (symbols, deps_graph)
|
||||
* - project:{name}:config # Hash<key, JSON>
|
||||
*
|
||||
* - session:{id}:data # Hash<field, JSON> (history, context, stats)
|
||||
* - session:{id}:undo # List<UndoEntry> (max 10)
|
||||
* - sessions:list # List<session_id>
|
||||
*
|
||||
* Project name format: {parent-folder}-{project-folder}
|
||||
*/
|
||||
|
||||
/**
|
||||
* Project-related Redis keys.
|
||||
*/
|
||||
export const ProjectKeys = {
|
||||
files: (projectName: string): string => `project:${projectName}:files`,
|
||||
ast: (projectName: string): string => `project:${projectName}:ast`,
|
||||
meta: (projectName: string): string => `project:${projectName}:meta`,
|
||||
indexes: (projectName: string): string => `project:${projectName}:indexes`,
|
||||
config: (projectName: string): string => `project:${projectName}:config`,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Session-related Redis keys.
|
||||
*/
|
||||
export const SessionKeys = {
|
||||
data: (sessionId: string): string => `session:${sessionId}:data`,
|
||||
undo: (sessionId: string): string => `session:${sessionId}:undo`,
|
||||
list: "sessions:list",
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Index field names within project:indexes hash.
|
||||
*/
|
||||
export const IndexFields = {
|
||||
symbols: "symbols",
|
||||
depsGraph: "deps_graph",
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Session data field names within session:data hash.
|
||||
*/
|
||||
export const SessionFields = {
|
||||
history: "history",
|
||||
context: "context",
|
||||
stats: "stats",
|
||||
inputHistory: "input_history",
|
||||
createdAt: "created_at",
|
||||
lastActivityAt: "last_activity_at",
|
||||
projectName: "project_name",
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Generate project name from path.
|
||||
* Format: {parent-folder}-{project-folder}
|
||||
*
|
||||
* @example
|
||||
* generateProjectName("/home/user/projects/myapp") -> "projects-myapp"
|
||||
* generateProjectName("/app") -> "app"
|
||||
*/
|
||||
export function generateProjectName(projectPath: string): string {
|
||||
const normalized = projectPath.replace(/\\/g, "/").replace(/\/+$/, "")
|
||||
const parts = normalized.split("/").filter(Boolean)
|
||||
|
||||
if (parts.length === 0) {
|
||||
return "root"
|
||||
}
|
||||
|
||||
if (parts.length === 1) {
|
||||
return sanitizeName(parts[0])
|
||||
}
|
||||
|
||||
const projectFolder = sanitizeName(parts[parts.length - 1])
|
||||
const parentFolder = sanitizeName(parts[parts.length - 2])
|
||||
|
||||
return `${parentFolder}-${projectFolder}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a name for use in Redis keys.
|
||||
* Replaces non-alphanumeric characters with hyphens.
|
||||
*/
|
||||
function sanitizeName(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "")
|
||||
}
|
||||
Reference in New Issue
Block a user