Guidelines for building backend services, API routes, and data access layers in the /web project using TypeScript, Supabase, and service-oriented architecture.
This skill provides guidance for developing backend services and API routes in the /web project for CRAN/E.
All data access is organized into service classes with static methods:
app/data/package.service.ts) - CRAN package operationsapp/data/author.service.ts) - Author/maintainer operationsapp/data/search.service.ts) - Universal searchapp/data/package-insight.service.server.ts) - Download stats, trendsapp/data/article.service.server.ts) - Press/blog contentapp/data/page-insight.service.ts) - AnalyticsAlways use the generated Supabase types from app/data/supabase.types.generated.ts:
import { Database, Tables } from "./supabase.types.generated";
// Type a specific table
type Package = Tables<"cran_packages">;
type Author = Tables<"authors">;
// Type the database client
import { createClient } from "@supabase/supabase-js";
export const supabase = createClient<Database>(url, key);
Regenerate types when schema changes:
npm run db.types # Requires Supabase CLI + credentials
Single shared client instance (app/data/supabase.server.ts):
import { createClient } from "@supabase/supabase-js";
import { Database } from "./supabase.types.generated";
export const supabase = createClient<Database>(
process.env.SUPABASE_URL,
process.env.SUPABASE_ANON_KEY,
);
Always:
from() and typed table names.error checks.maybeSingle() for nullable results, .single() for required results.select("*") or specific columns for type safetyExample query:
const result = await supabase
.from("cran_packages")
.select("*")
.eq("name", packageName)
.maybeSingle();
if (result.error || !result.data) {
return null;
}
return result.data;
Define schemas in *.shape.ts files:
import { z } from "zod";
export const packageNameSchema = z.string().min(1).max(300);
export const packageIdSchema = z.number().int().positive().min(1);
export type PackageSlug = z.infer<typeof packageNameSchema>;
Validate inputs at service boundaries:
static async getPackageByName(name: string) {
packageNameSchema.parse(name); // Throws if invalid
// ... proceed with query
}
Use TTLCache for expensive operations:
import TTLCache from "@isaacs/ttlcache";
import { hoursToMilliseconds, minutesToMilliseconds } from "date-fns";
class PackageService {
private static cache = new TTLCache<CacheKey, CacheValue>({
ttl: hoursToMilliseconds(6),
max: 1000,
});
static async getData(key: string) {
const cached = this.cache.get(key);
if (cached) return cached;
const data = await fetchData();
this.cache.set(key, data);
return data;
}
}
Cache keys: Use string literals or union types for type safety.
TTL guidelines:
export class ExampleService {
// Private static cache
private static cache = new TTLCache<CacheKey, CacheValue>({
ttl: hoursToMilliseconds(6),
});
// Static methods only (stateless)
static async getById(id: number) {
// 1. Validate input
idSchema.parse(id);
// 2. Check cache
const cached = this.cache.get(`id:${id}`);
if (cached) return cached;
// 3. Query database
const result = await supabase
.from("table_name")
.select("*")
.eq("id", id)
.maybeSingle();
// 4. Handle errors
if (result.error) {
slog.error("Query failed", { error: result.error, id });
return null;
}
// 5. Cache and return
if (result.data) {
this.cache.set(`id:${id}`, result.data);
}
return result.data;
}
}
File: app/routes/api.search._index.ts
import { ActionFunction } from "react-router";
import { SearchService } from "../data/search.service";
export const action: ActionFunction = async ({ request }) => {
const formData = await request.formData();
const intent = formData.get("intent");
if (intent === "all") {
const query = String(formData.get("q")).slice(0, 100);
const result = await SearchService.searchUniversal(query);
return Response.json(result);
}
throw new Error("Invalid intent");
};
Use for:
export const loader: LoaderFunction = async ({ params, request }) => {
const { packageName } = params;
const data = await PackageService.getPackageByName(packageName);
if (!data) {
throw data(null, { status: 404 });
}
return data(data, {
headers: {
"Cache-Control": "public, max-age=3600",
},
});
};
Use for:
File: app/routes/api.mcp.ts
Singleton transport pattern for stateful MCP connections:
let transport: WebStandardStreamableHTTPServerTransport | null = null;
function getTransport() {
if (!transport) {
transport = new WebStandardStreamableHTTPServerTransport({});
const server = getMcpServer();
server.connect(transport);
}
return transport;
}
export async function loader({ request }: LoaderFunctionArgs) {
return getTransport().handleRequest(request);
}
Use structured logging (app/modules/observability.server.ts):
import { slog } from "../modules/observability.server";
try {
const result = await someOperation();
} catch (error) {
slog.error("Operation failed", {
error,
context: { userId, action },
});
throw error;
}
Log levels:
slog.info() - Informational eventsslog.warn() - Warning conditionsslog.error() - Error conditionsimport { Tables } from "../data/supabase.types.generated";
type Pkg = Tables<"cran_packages">;
type Author = Tables<"authors"> & { roles: string[] };
Define in app/data/types.ts:
export type PackageRelationshipType =
| "depends"
| "imports"
| "suggests"
| "linking_to"
| "enhances"
| "reverse_depends"
| "reverse_imports"
| "reverse_suggests"
| "reverse_enhances"
| "reverse_linking_to";
export type PackageDependency = {
relationship_type: PackageRelationshipType;
version: string | null;
related_package: { id: number; name: string };
};
type LoaderData = {
item: Tables<"cran_packages">;
relations: Partial<Record<PackageRelationshipType, PackageDependency[]>>;
authors: Author[];
maintainer: Author;
// ... other fields
};
export const loader: LoaderFunction = async ({ params }) => {
const data: LoaderData = await buildLoaderData(params);
return data(data);
};
Example: CRAN Logs API (PackageInsightService)
class PackageInsightService {
private static readonly CRAN_LOGS_URL = "https://cranlogs.r-pkg.org";
private static async fetchFromCRAN<T>(path: string): Promise<T | null> {
try {
const response = await fetch(`${this.CRAN_LOGS_URL}${path}`);
if (!response.ok) return null;
return await response.json();
} catch (error) {
slog.error("CRAN API fetch failed", { error, path });
return null;
}
}
static async getTopDownloadedPackages(period: string, count: number) {
const cached = this.cache.get(`/top/${period}/${count}`);
if (cached) return cached;
const data = await this.fetchFromCRAN<TopDownloadsResponse>(
`/top/${period}/${count}`
);
if (data) this.cache.set(`/top/${period}/${count}`, data);
return data || [];
}
}
Use Promise.allSettled() for parallel operations with error handling:
const [packages, authors] = await Promise.allSettled([
PackageService.searchPackages(query),
AuthorService.searchAuthors(query),
]);
if (packages.status === "rejected") {
slog.error("Package search failed", { error: packages.reason });
}
const packageHits =
packages.status === "fulfilled"
? packages.value
: { combined: [], isSemanticPreferred: false };
Define in app/data/env.ts with Zod validation:
import { z } from "zod";
const envSchema = z.object({
SUPABASE_URL: z.string().url(),
SUPABASE_ANON_KEY: z.string().min(1),
// ... other vars
});
export const env = envSchema.parse(process.env);
Access: import { env } from "./data/env";
const packages = await supabase
.from("cran_packages")
.select("id, name, title")
.limit(10);
const result = await supabase
.from("cran_packages")
.select("*")
.eq("name", packageName)
.maybeSingle();
const result = await supabase
.from("package_relations")
.select(`
relationship_type,
version,
related_package:related_package_id (
id,
name
)
`)
.eq("package_id", packageId);
const results = await supabase
.from("cran_packages")
.select("name, title, description")
.textSearch("name", query, { type: "websearch" })
.limit(50);
const embeddings = await supabase.rpc("match_package_embeddings", {
query_embedding: embedding,
match_threshold: 0.5,
match_count: 10,
});
.limit() to prevent over-fetchingSELECT * when possiblePromise.all() or Promise.allSettled()uniqBy() from es-toolkitUse es-toolkit for data manipulation:
import { groupBy, uniqBy, omit } from "es-toolkit";
const grouped = groupBy(items, (item) => item.type);
const unique = uniqBy(items, (item) => item.id);
const cleaned = omit(obj, ["sensitiveField"]);
Use date-fns for date operations:
import {
format,
formatRelative,
subDays,
hoursToMilliseconds,
minutesToSeconds,
} from "date-fns";
Embeddings for semantic search:
import { google } from "@ai-sdk/google";
import { embed } from "ai";
const { embedding } = await embed({
model: google.textEmbeddingModel("text-embedding-004"),
value: query,
});
/web/app/data/*.service.ts or *.service.server.ts/web/app/data/*.shape.ts (Zod), *.types.generated.ts (Supabase)/web/app/data/types.ts/web/app/routes/api.*.ts/web/app/modules/*.server.tsFiles with .server.ts suffix are excluded from client bundles. Use for:
npm run typecheck catches type errorsnpm run build validates server code compilesnpm run lint enforces code quality.error propertyany types - Leverage generated Supabase types.server.ts suffix - Keep server code out of client bundlelegacy-peer-deps = trueSUPABASE_URL, SUPABASE_ANON_KEYGOOGLE_GENERATIVE_AI_API_KEY for embeddingsSearch for places (restaurants, cafes, etc.) via Google Places API proxy on localhost.
Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries.
Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets.
Start voice calls via the OpenClaw voice-call plugin.
Notion API for creating and managing pages, databases, and blocks.
Gemini CLI for one-shot Q&A, summaries, and generation.
Category:developer