import { ConvexError, v } from 'convex/values'; import type { Doc } from './_generated/dataModel'; import { internalMutation, internalQuery, mutation, query, } from './_generated/server'; import { getRequiredUserId, normalizeDotfilePath } from './model'; const fileMeta = (file: Doc<'userDotfiles'>) => ({ _id: file._id, path: file.path, size: file.size, isExecutable: file.isExecutable ?? false, updatedAt: file.updatedAt, }); /** Lists the user's dotfile tree (metadata only; content is fetched per-file). */ export const listMine = query({ args: {}, handler: async (ctx) => { const ownerId = await getRequiredUserId(ctx); const files = await ctx.db .query('userDotfiles') .withIndex('by_owner', (q) => q.eq('ownerId', ownerId)) .collect(); return files.map(fileMeta).sort((a, b) => a.path.localeCompare(b.path)); }, }); export const remove = mutation({ args: { fileId: v.id('userDotfiles') }, handler: async (ctx, { fileId }) => { const ownerId = await getRequiredUserId(ctx); const file = await ctx.db.get(fileId); if (file?.ownerId !== ownerId) throw new ConvexError('Dotfile not found.'); await ctx.db.delete(fileId); return { success: true }; }, }); /** Removes every file under a directory prefix (e.g. deleting ".config/nvim"). */ export const removeDirectory = mutation({ args: { prefix: v.string() }, handler: async (ctx, { prefix }) => { const ownerId = await getRequiredUserId(ctx); const normalized = normalizeDotfilePath(prefix); const files = await ctx.db .query('userDotfiles') .withIndex('by_owner', (q) => q.eq('ownerId', ownerId)) .collect(); const matches = files.filter( (f) => f.path === normalized || f.path.startsWith(`${normalized}/`), ); await Promise.all(matches.map((f) => ctx.db.delete(f._id))); return { removed: matches.length }; }, }); export const rename = mutation({ args: { fileId: v.id('userDotfiles'), path: v.string() }, handler: async (ctx, { fileId, path }) => { const ownerId = await getRequiredUserId(ctx); const file = await ctx.db.get(fileId); if (file?.ownerId !== ownerId) throw new ConvexError('Dotfile not found.'); const normalized = normalizeDotfilePath(path); const clash = await ctx.db .query('userDotfiles') .withIndex('by_owner_path', (q) => q.eq('ownerId', ownerId).eq('path', normalized), ) .unique(); if (clash && clash._id !== fileId) { throw new ConvexError(`A dotfile already exists at ${normalized}.`); } await ctx.db.patch(fileId, { path: normalized, updatedAt: Date.now() }); return { success: true }; }, }); // Read by the decrypting Node action (userDotfilesNode.getFileContent). export const getRawFileInternal = internalQuery({ args: { fileId: v.id('userDotfiles') }, handler: async (ctx, { fileId }) => await ctx.db.get(fileId), }); // Called by the encrypting Node action (userDotfilesNode). Upserts one file by // (owner, path). export const upsertFileInternal = internalMutation({ args: { ownerId: v.id('users'), path: v.string(), encryptedContent: v.string(), size: v.number(), isExecutable: v.optional(v.boolean()), }, handler: async (ctx, args) => { const existing = await ctx.db .query('userDotfiles') .withIndex('by_owner_path', (q) => q.eq('ownerId', args.ownerId).eq('path', args.path), ) .unique(); const now = Date.now(); if (existing) { await ctx.db.patch(existing._id, { encryptedContent: args.encryptedContent, size: args.size, isExecutable: args.isExecutable, updatedAt: now, }); return existing._id; } return await ctx.db.insert('userDotfiles', { ...args, updatedAt: now, }); }, });