v3: the backend is documented for deno doc, one way in every file, and the docs build and publish themselves — comments were in three shapes: file headers as // lines, as an untagged /** */ (which deno doc drops, only an @module block counts) or missing; 43 exports described by a // deno doc can't see, two JSDoc blocks sitting on the wrong declaration and one duplicated; endpoints commented in 5 of 18 route files, three ways, on router.get() calls no doc tool reads. The rule, applied to every non-test module of api/, migrations included: each opens with a /** … @module */ block; every export has JSDoc, and so does every field of an exported interface; a comment describing a declaration is /** */, // only notes code; functions open with a verb, everything else with a noun phrase; parameters are named in the prose, {@link} only for symbols in scope; whatever throws to its caller says so with @throws (for an APIException, the statuses and when); a router's JSDoc lists its endpoints, method and path, who may call it, what it does. deno doc --lint goes from 528 errors to none in api/ (shared/ keeps its 385; it is next). The lint also wanted explicit types on config.ts's settings and a few return types, JsonLdResult exported, and the routers typed Router. deno task doc builds the HTML reference into docs/ from every module of api/ and shared/ but the tests, deno task doc:lint lints it; the index page is the module doc of a root mod.ts (deno.json's exports), which introduces the backend, shared/ and the frontend to come and links every module, and mod.test.ts fails when one is missing. The docs workflow builds it on every push to main that touches them and publishes it to the gh-pages branch, like publish-assistant's. Two defects found along the way are fixed: a private playlist's cover was served to anyone who had its id or slug, and now needs its owner's media token, as private dump files do (playlistImageUrl adds it for private playlists only, so public cover URLs stay the same for everyone); og.ts and static.ts each filled index.html's placeholders, and now share lib/index-html.ts, so the OG middleware picks up a rebuilt index.html instead of keeping the first it read. The README states the documentation rule. 205 backend and 628 frontend tests pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* Categories: managing them, and which dumps belong to which.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { APIErrorCode } from "../../shared/api.ts";
|
||||
import { APIException } from "../lib/api-exception.ts";
|
||||
import type {
|
||||
@@ -17,6 +23,7 @@ import { type Page, pageOf } from "../lib/pagination.ts";
|
||||
import { isReservedSlug, isValidSlugFormat } from "../lib/reserved-slugs.ts";
|
||||
import { VALIDATION } from "../../shared/validation.ts";
|
||||
|
||||
/** Throws a 400 unless `slug` is well-formed, short enough, and not reserved. */
|
||||
function validateSlug(slug: string): void {
|
||||
if (!isValidSlugFormat(slug)) {
|
||||
throw new APIException(
|
||||
@@ -41,6 +48,7 @@ function validateSlug(slug: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns `name` trimmed, or throws a 400 when it is blank or too long. */
|
||||
function requireName(name: string): string {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) {
|
||||
@@ -56,7 +64,10 @@ function requireName(name: string): string {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
// Maps SQLite UNIQUE violations to a friendly 409 naming the offending field.
|
||||
/**
|
||||
* Rethrows a SQLite UNIQUE violation as a friendly 409 naming the offending
|
||||
* field, and anything else as is.
|
||||
*/
|
||||
function uniqueConflict(err: unknown, slug: string, name: string): never {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (msg.includes("categories.name")) {
|
||||
@@ -76,6 +87,11 @@ function uniqueConflict(err: unknown, slug: string, name: string): never {
|
||||
throw err as Error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns category `id`.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such category.
|
||||
*/
|
||||
export function getCategoryById(id: string): Category {
|
||||
const row = db.prepare(`SELECT * FROM categories WHERE id = ?;`).get(id);
|
||||
if (!row || !isCategoryRow(row)) {
|
||||
@@ -84,6 +100,11 @@ export function getCategoryById(id: string): Category {
|
||||
return categoryRowToApi(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the category at `slug`.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such category.
|
||||
*/
|
||||
export function getCategoryBySlug(slug: string): Category {
|
||||
const row = db.prepare(`SELECT * FROM categories WHERE slug = ?;`).get(slug);
|
||||
if (!row || !isCategoryRow(row)) {
|
||||
@@ -92,7 +113,7 @@ export function getCategoryBySlug(slug: string): Category {
|
||||
return categoryRowToApi(row);
|
||||
}
|
||||
|
||||
/** Resolve a slug to its category id, or undefined if no such category. */
|
||||
/** Returns the id of the category at `slug`, or undefined when there is none. */
|
||||
export function getCategoryIdBySlug(slug: string): string | undefined {
|
||||
const row = db.prepare(`SELECT id FROM categories WHERE slug = ?;`).get(slug);
|
||||
return row && typeof (row as { id?: unknown }).id === "string"
|
||||
@@ -100,6 +121,7 @@ export function getCategoryIdBySlug(slug: string): string | undefined {
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/** Returns every category, by position then slug. */
|
||||
export function listCategories(): Category[] {
|
||||
const rows = db.prepare(
|
||||
`SELECT * FROM categories ORDER BY position ASC, slug ASC;`,
|
||||
@@ -107,6 +129,12 @@ export function listCategories(): Category[] {
|
||||
return rows.filter(isCategoryRow).map(categoryRowToApi);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a category.
|
||||
*
|
||||
* @throws {APIException} 400 when the slug or name is invalid, 409 when
|
||||
* either is taken.
|
||||
*/
|
||||
export function createCategory(req: CreateCategoryRequest): Category {
|
||||
validateSlug(req.slug);
|
||||
const name = requireName(req.name);
|
||||
@@ -137,6 +165,12 @@ export function createCategory(req: CreateCategoryRequest): Category {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies `req` to category `id`.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such category, 400 when the new
|
||||
* slug or name is invalid, 409 when either is taken.
|
||||
*/
|
||||
export function updateCategory(
|
||||
id: string,
|
||||
req: UpdateCategoryRequest,
|
||||
@@ -176,6 +210,11 @@ export function updateCategory(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes category `id`; its dumps stay, outside it.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such category.
|
||||
*/
|
||||
export function deleteCategory(id: string): void {
|
||||
const result = db.prepare(`DELETE FROM categories WHERE id = ?;`).run(id);
|
||||
if (result.changes === 0) {
|
||||
@@ -184,9 +223,11 @@ export function deleteCategory(id: string): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws a 400 unless every id names an existing category. Callers that write
|
||||
* Checks that every id names an existing category. Callers that write
|
||||
* anything else first (a dump row, an uploaded file) run this before those
|
||||
* writes, so a bad id can't leave them half-done.
|
||||
*
|
||||
* @throws {APIException} 400 when an id names no category.
|
||||
*/
|
||||
export function assertCategoriesExist(categoryIds: string[]): void {
|
||||
const unique = [...new Set(categoryIds)];
|
||||
@@ -207,8 +248,10 @@ export function assertCategoriesExist(categoryIds: string[]): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the set of categories a dump belongs to. Unknown category ids are
|
||||
* rejected. An empty list clears all category assignments.
|
||||
* Replaces the set of categories a dump belongs to. An empty list clears all
|
||||
* category assignments.
|
||||
*
|
||||
* @throws {APIException} 400 when an id names no category.
|
||||
*/
|
||||
export function setDumpCategories(
|
||||
dumpId: string,
|
||||
@@ -228,7 +271,7 @@ export function setDumpCategories(
|
||||
});
|
||||
}
|
||||
|
||||
/** Fill `categoryIds` on each dump with a single batched lookup. */
|
||||
/** Fills `categoryIds` on each dump with a single batched lookup. */
|
||||
export function attachCategoryIds(dumps: Dump[]): void {
|
||||
if (dumps.length === 0) return;
|
||||
const ids = dumps.map((d) => d.id);
|
||||
@@ -248,6 +291,12 @@ export function attachCategoryIds(dumps: Dump[]): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a page of the dumps in the category at `slug`, newest first, with
|
||||
* the requesting user's private ones.
|
||||
*
|
||||
* @throws {APIException} 404 when there is no such category.
|
||||
*/
|
||||
export function listDumpsByCategory(
|
||||
slug: string,
|
||||
page: number,
|
||||
|
||||
Reference in New Issue
Block a user