refactor: prepare server for machine sync refactoring

- Clean up machine API endpoints formatting
- Update machine-alive to use ephemeral events instead of updates
- Prepare types for separated metadata and daemonState
- Fix activeAt field name consistency in machine responses

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Kirill Dubovitskiy 2025-08-17 18:32:31 -07:00
parent 6b1a3c3e82
commit d03240061d
3 changed files with 285 additions and 172 deletions

View File

@ -13,7 +13,7 @@
"migrate": "dotenv -e .env.dev -- prisma migrate dev",
"generate": "prisma generate",
"postinstall": "prisma generate",
"db": "docker run -d -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=handy -v postgres-data:/var/lib/postgresql/data -p 5432:5432 postgres",
"db": "docker run -d -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=handy -v $(pwd)/.pgdata:/var/lib/postgresql/data -p 5432:5432 postgres",
"redis": "docker run -d -p 6379:6379 redis"
},
"devDependencies": {

View File

@ -15,9 +15,9 @@ import { AsyncLock } from "@/utils/lock";
// Recipient filter types
type RecipientFilter =
| { type: 'all-interested-in-session'; sessionId: string }
| { type: 'user-scoped-only' }
| { type: 'all-user-authenticated-connections' };
| { type: 'all-interested-in-session'; sessionId: string }
| { type: 'user-scoped-only' }
| { type: 'all-user-authenticated-connections' };
// Connection metadata types
interface SessionScopedConnection {
@ -940,6 +940,93 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }>
});
});
// Machines
// POST /v1/machines - Create machine or return existing
typed.post('/v1/machines', {
preHandler: app.authenticate,
schema: {
body: z.object({
id: z.string(),
metadata: z.string() // Encrypted metadata
})
}
}, async (request, reply) => {
const userId = request.user.id;
const { id, metadata } = request.body;
// Check if machine exists (like sessions do)
const machine = await db.machine.findFirst({
where: {
accountId: userId,
id: id
}
});
if (machine) {
// Machine exists - just return it
logger.info({ module: 'machines', machineId: id, userId }, 'Found existing machine');
return reply.send({
machine: {
id: machine.id,
metadata: machine.metadata,
metadataVersion: machine.metadataVersion,
active: machine.active,
lastActiveAt: machine.lastActiveAt.getTime(),
createdAt: machine.createdAt.getTime(),
updatedAt: machine.updatedAt.getTime()
}
});
} else {
// Create new machine
logger.info({ module: 'machines', machineId: id, userId }, 'Creating new machine');
const newMachine = await db.machine.create({
data: {
id,
accountId: userId,
metadata,
metadataVersion: 1
// active defaults to true in schema
// lastActiveAt defaults to now() in schema
}
});
// Emit update for new machine
const updSeq = await allocateUserSeq(userId);
emitUpdateToInterestedClients({
event: 'update',
userId,
payload: {
id: randomKeyNaked(),
seq: updSeq,
body: {
t: 'update-machine',
id: newMachine.id,
metadata: {
version: 1,
value: metadata
}
},
createdAt: Date.now()
}
});
return reply.send({
machine: {
id: newMachine.id,
metadata: newMachine.metadata,
metadataVersion: 1,
active: newMachine.active,
lastActiveAt: newMachine.lastActiveAt.getTime(),
createdAt: newMachine.createdAt.getTime(),
updatedAt: newMachine.updatedAt.getTime()
}
});
}
});
// Machines API
typed.get('/v1/machines', {
preHandler: app.authenticate,
@ -957,83 +1044,47 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }>
metadataVersion: m.metadataVersion,
seq: m.seq,
active: m.active,
lastActiveAt: m.lastActiveAt.getTime(),
activeAt: m.lastActiveAt.getTime(),
createdAt: m.createdAt.getTime(),
updatedAt: m.updatedAt.getTime()
}));
});
// POST /v1/machines - Create or update machine
typed.post('/v1/machines', {
// GET /v1/machines/:id - Get single machine by ID
typed.get('/v1/machines/:id', {
preHandler: app.authenticate,
schema: {
body: z.object({
id: z.string(),
metadata: z.string() // Encrypted metadata
params: z.object({
id: z.string()
})
}
}, async (request, reply) => {
const userId = request.user.id;
const { id, metadata } = request.body;
const { id } = request.params;
logger.info({ module: 'machines', machineId: id, userId, hasMetadata: !!metadata }, 'Creating/updating machine');
try {
const machine = await db.machine.upsert({
const machine = await db.machine.findFirst({
where: {
accountId_id: {
accountId: userId,
id
}
},
create: {
id,
accountId: userId,
metadata,
metadataVersion: 1
},
update: {
metadata,
metadataVersion: { increment: 1 },
active: true,
lastActiveAt: new Date()
id: id
}
});
});
// Emit update to all user connections
const updSeq = await allocateUserSeq(userId);
emitUpdateToInterestedClients({
event: 'update',
userId,
payload: {
id: randomKeyNaked(),
seq: updSeq,
body: {
t: 'update-machine',
id: machine.id,
metadata: {
version: machine.metadataVersion,
value: metadata
}
},
createdAt: Date.now()
}
});
return { success: true };
} catch (error) {
logger.error({
module: 'machines',
machineId: id,
userId,
error: error instanceof Error ? error.message : String(error),
errorStack: error instanceof Error ? error.stack : undefined
}, 'Failed to create/update machine');
return reply.code(500).send({
error: 'Failed to create/update machine',
details: error instanceof Error ? error.message : String(error)
});
if (!machine) {
return reply.code(404).send({ error: 'Machine not found' });
}
return {
machine: {
id: machine.id,
metadata: machine.metadata,
metadataVersion: machine.metadataVersion,
seq: machine.seq,
active: machine.active,
activeAt: machine.lastActiveAt.getTime(),
createdAt: machine.createdAt.getTime(),
updatedAt: machine.updatedAt.getTime()
}
};
});
// Combined logging endpoint (only when explicitly enabled)
@ -1336,49 +1387,45 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }>
return;
}
const machineId = data.machineId;
// Update machine lastActiveAt in database
const machine = await db.machine.update({
// Resolve machine
const machine = await db.machine.findUnique({
where: {
accountId_id: {
accountId: userId,
id: machineId
id: data.machineId
}
}
});
if (!machine) {
return;
}
// Update machine lastActiveAt in database
const updatedMachine = await db.machine.update({
where: {
accountId_id: {
accountId: userId,
id: data.machineId
}
},
data: {
lastActiveAt: new Date(t),
active: true
}
}).catch(() => {
// Machine might not exist yet, that's ok
return null;
});
})
// If machine was updated, emit update to all user connections
if (machine) {
const updSeq = await allocateUserSeq(userId);
emitUpdateToInterestedClients({
event: 'update',
userId,
payload: {
id: randomKeyNaked(12),
seq: updSeq,
body: {
t: 'update-machine',
id: machine.id,
metadata: machine.metadata ? {
version: machine.metadataVersion,
value: machine.metadata
} : undefined,
active: true,
lastActiveAt: t
},
createdAt: Date.now()
},
recipientFilter: { type: 'all-user-authenticated-connections' }
});
}
emitUpdateToInterestedClients({
event: 'ephemeral',
userId,
payload: {
type: 'machine-activity',
id: updatedMachine.id,
active: true,
lastActiveAt: t,
},
recipientFilter: { type: 'user-scoped-only' }
});
} catch (error) {
log({ module: 'websocket', level: 'error' }, `Error in machine-alive: ${error}`);
}
@ -1660,50 +1707,108 @@ export async function startApi(): Promise<{ app: FastifyInstance; io: Server }>
}
});
// Update machine through socket
socket.on('update-machine', async (data: { metadata: string }) => {
if (connection.connectionType !== 'machine-scoped') {
return; // Only machines can update their own metadata
}
const machineId = connection.machineId;
// Machine metadata update with optimistic concurrency control
socket.on('machine-update-metadata', async (data: any, callback: (response: any) => void) => {
try {
const machine = await db.machine.update({
const { machineId, metadata, expectedVersion } = data;
// Validate input
if (!machineId || typeof metadata !== 'string' || typeof expectedVersion !== 'number') {
if (callback) {
callback({ result: 'error', message: 'Invalid parameters' });
}
return;
}
// Resolve machine
const machine = await db.machine.findFirst({
where: {
accountId_id: {
accountId: userId,
id: machineId
}
accountId: userId,
id: machineId
}
});
if (!machine) {
if (callback) {
callback({ result: 'error', message: 'Machine not found' });
}
return;
}
// Check version
if (machine.metadataVersion !== expectedVersion) {
callback({
result: 'version-mismatch',
version: machine.metadataVersion,
metadata: machine.metadata
});
return;
}
// Update metadata with atomic version check
const { count } = await db.machine.updateMany({
where: {
accountId: userId,
id: machineId,
metadataVersion: expectedVersion // Atomic CAS
},
data: {
metadata: data.metadata,
metadataVersion: { increment: 1 }
metadata: metadata,
metadataVersion: expectedVersion + 1
// NOT updating active or lastActiveAt here
}
});
// Emit to other connections
if (count === 0) {
// Re-fetch current version
const current = await db.machine.findFirst({
where: {
accountId: userId,
id: machineId
}
});
callback({
result: 'version-mismatch',
version: current?.metadataVersion || 0,
metadata: current?.metadata
});
return;
}
// Generate update
const updSeq = await allocateUserSeq(userId);
const updContent: PrismaJson.UpdateBody = {
t: 'update-machine',
id: machineId,
metadata: {
value: metadata,
version: expectedVersion + 1
}
};
// Emit to all connections
emitUpdateToInterestedClients({
event: 'update',
userId,
payload: {
id: randomKeyNaked(),
id: randomKeyNaked(12),
seq: updSeq,
body: {
t: 'update-machine',
id: machineId,
metadata: {
version: machine.metadataVersion,
value: data.metadata
}
},
body: updContent,
createdAt: Date.now()
},
skipSenderConnection: connection
recipientFilter: { type: 'all-user-authenticated-connections' }
});
// Send success response with new version
callback({
result: 'success',
version: expectedVersion + 1,
metadata: metadata
});
} catch (error) {
log({ module: 'websocket', level: 'error' }, `Error updating machine metadata: ${error}`);
log({ module: 'websocket', level: 'error' }, `Error in machine-update-metadata: ${error}`);
if (callback) {
callback({ result: 'error', message: 'Internal error' });
}
}
});

View File

@ -60,6 +60,14 @@ declare global {
value: string | null;
version: number;
} | null | undefined;
} | {
t: 'update-machine';
id: string;
metadata?: {
value: string;
version: number;
};
activeAt?: number;
};
}
}