From 0ccdef14ff919c2c87fa2cffb550056a8490bea1 Mon Sep 17 00:00:00 2001 From: Steve Korshakov Date: Mon, 14 Jul 2025 18:47:47 -0700 Subject: [PATCH] feat: add metadata version, agent state, version, add netadata change --- CLAUDE.md | 178 ++++++++++++++++++ .../migration.sql | 4 + prisma/schema.prisma | 25 +-- sources/app/api.ts | 105 ++++++++++- sources/storage/types.ts | 14 ++ 5 files changed, 313 insertions(+), 13 deletions(-) create mode 100644 CLAUDE.md create mode 100644 prisma/migrations/20250715012822_add_metadata_version_agent_state/migration.sql diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ff6d3c0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,178 @@ +# Handy Server - Development Guidelines + +This document contains the development guidelines and instructions for the Handy Server project. This guide OVERRIDES any default behaviors and MUST be followed exactly. + +## Project Overview + +**Name**: handy-server +**Repository**: https://github.com/ex3ndr/handy-server.git +**License**: MIT +**Language**: TypeScript +**Runtime**: Node.js 20 +**Framework**: Fastify with opinionated architecture + +## Core Technology Stack + +- **Runtime**: Node.js 20 +- **Language**: TypeScript (strict mode enabled) +- **Web Framework**: Fastify 5 +- **Database**: PostgreSQL with Prisma ORM +- **Validation**: Zod +- **HTTP Client**: Axios +- **Real-time**: Socket.io +- **Cache/Pub-Sub**: Redis (via ioredis) +- **Testing**: Vitest +- **Package Manager**: Yarn (not npm) + +## Development Environment + +### Commands +- `yarn build` - TypeScript type checking +- `yarn start` - Start the server +- `yarn test` - Run tests +- `yarn migrate` - Run Prisma migrations +- `yarn generate` - Generate Prisma client +- `yarn db` - Start local PostgreSQL in Docker + +### Environment Requirements +- FFmpeg installed (for media processing) +- Python3 installed +- PostgreSQL database +- Redis (for event bus and caching) + +## Code Style and Structure + +### General Principles +- Use 4 spaces for tabs (not 2 spaces) +- Write concise, technical TypeScript code with accurate examples +- Use functional and declarative programming patterns; avoid classes +- Prefer iteration and modularization over code duplication +- Use descriptive variable names with auxiliary verbs (e.g., isLoading, hasError) +- All sources must be imported using "@/" prefix (e.g., `import "@/utils/log"`) +- Always use absolute imports +- Prefer interfaces over types +- Avoid enums; use maps instead +- Use strict mode in TypeScript for better type safety + +### Folder Structure +``` +/sources # Root of the sources +├── /app # Application entry points +│ ├── api.ts # API server setup +│ └── timeout.ts # Timeout handling +├── /apps # Applications directory +│ └── /api # API server application +│ └── /routes # API routes +├── /modules # Reusable modules (non-application logic) +├── /utils # Low level or abstract utilities +├── /recipes # Scripts to run outside of the server +├── /services # Core services +│ └── pubsub.ts # Pub/sub service +├── /storage # Database and storage utilities +│ ├── db.ts # Database client +│ ├── inTx.ts # Transaction wrapper +│ ├── repeatKey.ts # Key utilities +│ ├── simpleCache.ts # Caching utility +│ └── types.ts # Storage types +└── main.ts # Main entry point +``` + +### Naming Conventions +- Use lowercase with dashes for directories (e.g., components/auth-wizard) +- When writing utility functions, always name file and function the same way +- Test files should have ".spec.ts" suffix + +## Tool Usage + +### Web Search and Fetching +- When in doubt, use web tool to get answers from the web +- Search web when you have some failures + +### File Operations +- NEVER create files unless they're absolutely necessary +- ALWAYS prefer editing existing files to creating new ones +- NEVER proactively create documentation files (*.md) or README files unless explicitly requested + +## Utilities + +### Writing Utility Functions +1. Always name file and function the same way for easy discovery +2. Utility functions should be modular and not too complex +3. Always write tests for utility functions BEFORE writing the code +4. Iterate implementation and tests until the function works as expected +5. Always write documentation for utility functions + +## Modules + +### Module Guidelines +- Modules are bigger than utility functions and abstract away complexity +- Each module should have a dedicated directory +- Modules usually don't have application-specific logic +- Modules can depend on other modules, but not on application-specific logic +- Prefer to write code as modules instead of application-specific code + +### When to Use Modules +- When integrating with external services +- When abstracting complexity of some library +- When implementing related groups of functions (math, date, etc.) + +### Known Modules +- **ai**: AI wrappers to interact with AI services +- **eventbus**: Event bus to send and receive events between modules and applications +- **lock**: Simple lock to synchronize access to resources in the whole cluster +- **media**: Tools to work with media files + +## Applications + +- Applications contain application-specific logic +- Applications have the most complexity; other parts should assist by reducing complexity +- When using prompts, write them to "_prompts.ts" file relative to the application + +## Database + +### Prisma Usage +- Prisma is used as ORM +- Use "inTx" to wrap database operations in transactions +- Do not update schema without absolute necessity +- For complex fields, use "Json" type + +### Current Schema Status +The project has pending Prisma migrations that need to be applied: +- Migration: `20250715012822_add_metadata_version_agent_state` + +## Events + +### Event Bus +- eventbus allows sending and receiving events inside the process and between different processes +- eventbus is local or redis based +- Use "afterTx" to send events after transaction is committed successfully instead of directly emitting events + +## Testing + +- Write tests using Vitest +- Test files should be named the same as source files with ".spec.ts" suffix +- For utility functions, write tests BEFORE implementation + +## API Development + +- API server is in `/sources/apps/api` +- Routes are in `/sources/apps/api/routes` +- Use Fastify with Zod for type-safe route definitions +- Always validate inputs using Zod + +## Docker Deployment + +The project includes a multi-stage Dockerfile: +1. Builder stage: Installs dependencies and builds the application +2. Runner stage: Minimal runtime with only necessary files +3. Exposes port 3000 +4. Requires FFmpeg and Python3 in the runtime + +## Important Reminders + +1. Do what has been asked; nothing more, nothing less +2. NEVER create files unless they're absolutely necessary for achieving your goal +3. ALWAYS prefer editing an existing file to creating a new one +4. NEVER proactively create documentation files (*.md) or README files unless explicitly requested +5. Use 4 spaces for tabs (not 2 spaces) +6. Use yarn instead of npm for all package management \ No newline at end of file diff --git a/prisma/migrations/20250715012822_add_metadata_version_agent_state/migration.sql b/prisma/migrations/20250715012822_add_metadata_version_agent_state/migration.sql new file mode 100644 index 0000000..a1490d8 --- /dev/null +++ b/prisma/migrations/20250715012822_add_metadata_version_agent_state/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE "Session" ADD COLUMN "agentState" TEXT, +ADD COLUMN "agentStateVersion" INTEGER NOT NULL DEFAULT 0, +ADD COLUMN "metadataVersion" INTEGER NOT NULL DEFAULT 0; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7952870..eb8a574 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -33,17 +33,20 @@ model Account { // model Session { - id String @id @default(cuid()) - tag String - accountId String - account Account @relation(fields: [accountId], references: [id]) - metadata String - seq Int @default(0) - active Boolean @default(true) - lastActiveAt DateTime @default(now()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - messages SessionMessage[] + id String @id @default(cuid()) + tag String + accountId String + account Account @relation(fields: [accountId], references: [id]) + metadata String + metadataVersion Int @default(0) + agentState String? + agentStateVersion Int @default(0) + seq Int @default(0) + active Boolean @default(true) + lastActiveAt DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + messages SessionMessage[] @@unique([accountId, tag]) } diff --git a/sources/app/api.ts b/sources/app/api.ts index 17b2c5e..242fcac 100644 --- a/sources/app/api.ts +++ b/sources/app/api.ts @@ -128,6 +128,9 @@ export async function startApi() { createdAt: true, updatedAt: true, metadata: true, + metadataVersion: true, + agentState: true, + agentStateVersion: true, active: true, lastActiveAt: true, messages: { @@ -152,6 +155,9 @@ export async function startApi() { active: v.active, activeAt: v.lastActiveAt.getTime(), metadata: v.metadata, + metadataVersion: v.metadataVersion, + agentState: v.agentState, + agentStateVersion: v.agentStateVersion, lastMessage: v.messages[0] ? { id: v.messages[0].id, seq: v.messages[0].seq, @@ -167,7 +173,8 @@ export async function startApi() { schema: { body: z.object({ tag: z.string(), - metadata: z.string() + metadata: z.string(), + agentState: z.string().nullish() }) }, preHandler: app.authenticate @@ -187,6 +194,9 @@ export async function startApi() { id: session.id, seq: session.seq, metadata: session.metadata, + metadataVersion: session.metadataVersion, + agentState: session.agentState, + agentStateVersion: session.agentStateVersion, active: session.active, activeAt: session.lastActiveAt.getTime(), createdAt: session.createdAt.getTime(), @@ -221,7 +231,10 @@ export async function startApi() { t: 'new-session', id: session.id, seq: session.seq, - metadata: metadata, + metadata: session.metadata, + metadataVersion: session.metadataVersion, + agentState: session.agentState, + agentStateVersion: session.agentStateVersion, active: session.active, activeAt: session.lastActiveAt.getTime(), createdAt: session.createdAt.getTime(), @@ -253,6 +266,9 @@ export async function startApi() { id: result.session.id, seq: result.session.seq, metadata: result.session.metadata, + metadataVersion: result.session.metadataVersion, + agentState: result.session.agentState, + agentStateVersion: result.session.agentStateVersion, active: result.session.active, activeAt: result.session.lastActiveAt.getTime(), createdAt: result.session.createdAt.getTime(), @@ -575,6 +591,91 @@ export async function startApi() { pubsub.emit('update', userId, result.update); }); + socket.on('update-metadata', async (data: any, callback: (response: any) => void) => { + const { sid, metadata, expectedVersion } = data; + + // Validate input + if (!sid || typeof metadata !== 'string' || typeof expectedVersion !== 'number') { + if (callback) { + callback({ result: 'error' }); + } + return; + } + + // Start transaction to ensure consistency + const result = await db.$transaction(async (tx) => { + // Verify session belongs to user and lock it + const session = await tx.session.findFirst({ + where: { + id: sid, + accountId: userId + } + }); + const user = await tx.account.findUnique({ + where: { id: userId } + }); + if (!user || !session) { + callback({ result: 'error' }); + return null; + } + + // Check version + if (session.metadataVersion !== expectedVersion) { + callback({ result: 'version-mismatch', version: session.metadataVersion, metadata: session.metadata }); + return null; + } + + // Get next sequence number + const updSeq = user.seq + 1; + const newMetadataVersion = session.metadataVersion + 1; + + // Update session metadata + await tx.session.update({ + where: { id: sid }, + data: { + metadata: metadata, + metadataVersion: newMetadataVersion + } + }); + + // Create update + const updContent: PrismaJson.UpdateBody = { + t: 'update-session', + id: sid, + metadata: { + value: metadata, + version: newMetadataVersion + } + }; + + const update = await tx.update.create({ + data: { + accountId: userId, + seq: updSeq, + content: updContent + } + }); + + // Update user sequence + await tx.account.update({ + where: { id: userId }, + data: { seq: updSeq } + }); + + return { update, newMetadataVersion }; + }); + if (!result) { + return; + } + + // Emit update to connected sockets + pubsub.emit('update', userId, result.update); + + // Send success response with new version via callback + callback({ success: true, metadataVersion: result.newMetadataVersion, metadata: metadata }); + + }); + socket.emit('auth', { success: true, user: userId }); log({ module: 'websocket' }, `User connected: ${userId}`); }); diff --git a/sources/storage/types.ts b/sources/storage/types.ts index ef69254..a5c869d 100644 --- a/sources/storage/types.ts +++ b/sources/storage/types.ts @@ -22,10 +22,24 @@ declare global { id: string; seq: number; metadata: string; + metadataVersion: number; + agentState: string | null; + agentStateVersion: number; active: boolean; activeAt: number; createdAt: number; updatedAt: number; + } | { + t: 'update-session' + id: string; + metadata?: { + value: string; + version: number; + } | null | undefined + agentState?: { + value: string; + version: number; + } | null | undefined }; } }