import { repoDb } from "../scoped-db"; import { accountsById } from "../people"; import { randomId } from "../crypto"; import { assertBelowCap } from "../limits"; import { HttpError, forbidden, notFoundError } from "../errors"; const MAX_LOCKS_PER_REPO = 1000; const MAX_LOCK_PATH = 1024; export const DEFAULT_LOCK_PAGE = 100; export const MAX_LOCK_PAGE = 100; export type LfsLock = { id: string; path: string; locked_at: string; owner: { name: string } }; type LockRow = { id: string; path: string; created_at: number; owner_id: string; owner_name: string }; const LOCK_SELECT = "SELECT l.id, l.path, l.created_at, l.owner_id FROM lfs_locks l"; /** Owner names come from the D1 directory; locks whose owner account is gone are dropped, as the old join did. */ async function withOwners(rows: Array>): Promise { const owners = await accountsById(rows.map((row) => row.owner_id)); return rows.flatMap((row) => { const owner = owners.get(row.owner_id); return owner ? [{ ...row, owner_name: owner.name }] : []; }); } async function findLock(repoId: string, where: string, value: string): Promise { const row = await repoDb(repoId).first>( `${LOCK_SELECT} WHERE l.repo_id = ? AND ${where}`, repoId, value, ); return row ? ((await withOwners([row]))[0] ?? null) : null; } export function lockJson(row: LockRow): LfsLock { return { id: row.id, path: row.path, locked_at: new Date(row.created_at).toISOString(), owner: { name: row.owner_name }, }; } /** Cursor is `:` for stable keyset pagination. */ function parseCursor(cursor: string | undefined): [number, string] | null { if (!cursor) return null; const separator = cursor.indexOf(":"); const createdAt = Number(cursor.slice(0, separator)); return separator > 0 && Number.isFinite(createdAt) ? [createdAt, cursor.slice(separator + 1)] : null; } export function clampLimit(value: unknown): number { const parsed = Number(value); if (!Number.isInteger(parsed) || parsed <= 0) return DEFAULT_LOCK_PAGE; return Math.min(parsed, MAX_LOCK_PAGE); } export async function listLocks( repoId: string, filter: { path?: string; id?: string; cursor?: string; limit: number }, ): Promise<{ rows: LockRow[]; nextCursor: string | null }> { const clauses = ["l.repo_id = ?"]; const params: (string | number)[] = [repoId]; if (filter.path) { clauses.push("l.path = ?"); params.push(filter.path); } if (filter.id) { clauses.push("l.id = ?"); params.push(filter.id); } const cursor = parseCursor(filter.cursor); if (cursor) { clauses.push("(l.created_at > ? OR (l.created_at = ? AND l.id >= ?))"); params.push(cursor[0], cursor[0], cursor[1]); } const rows = await repoDb(repoId).all>( `${LOCK_SELECT} WHERE ${clauses.join(" AND ")} ORDER BY l.created_at, l.id LIMIT ?`, ...params, filter.limit + 1, ); const next = rows.length > filter.limit ? rows[filter.limit] : null; return { rows: await withOwners(rows.slice(0, filter.limit)), nextCursor: next ? `${next.created_at}:${next.id}` : null, }; } export async function createLock( repoId: string, ownerId: string, path: string, refName: string | null, ): Promise<{ lock: LockRow; created: boolean }> { if (!path || path.length > MAX_LOCK_PATH || path.includes("\0")) throw new HttpError(422, "Invalid lock path"); const existing = await findLock(repoId, "l.path = ?", path); if (existing) return { lock: existing, created: false }; const count = await repoDb(repoId).first<{ count: number }>( "SELECT COUNT(*) AS count FROM lfs_locks WHERE repo_id = ?", repoId, ); assertBelowCap(count?.count ?? 0, MAX_LOCKS_PER_REPO, "LFS locks per repository"); const id = randomId(); const result = await repoDb(repoId).run( `INSERT INTO lfs_locks (id, repo_id, path, ref_name, owner_id, created_at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (repo_id, path) DO NOTHING`, id, repoId, path, refName, ownerId, Date.now(), ); const lock = await findLock(repoId, "l.path = ?", path); if (!lock) throw new HttpError(500, "Lock vanished"); return { lock, created: result.meta.changes > 0 }; } /** Owners may always unlock; anyone else needs `force` plus admin rights on the repository. */ export async function deleteLock( repoId: string, lockId: string, actorId: string, options: { force: boolean; isAdmin: boolean }, ): Promise { const lock = await findLock(repoId, "l.id = ?", lockId); if (!lock) throw notFoundError("Lock not found"); if (lock.owner_id !== actorId) { if (!options.force) throw forbidden("Lock is owned by another user; use --force"); if (!options.isAdmin) throw forbidden("Only repository admins can force-unlock"); } await repoDb(repoId).run("DELETE FROM lfs_locks WHERE id = ?", lock.id); return lock; } export async function countLocks(repoId: string): Promise { const row = await repoDb(repoId).first<{ count: number }>( "SELECT COUNT(*) AS count FROM lfs_locks WHERE repo_id = ?", repoId, ); return row?.count ?? 0; }