Rebasely Beta: data may be purged

examples/storefront Public

TypeScript · 150 lines · 5.9 KiB · at 54fcfaa

// The product catalog: what the shop sells, at what price, and how many are left.import { HttpError } from "./http.ts";import type { ImageUrls } from "./images.ts";export type Product = {  id: string;  name: string;  description: string;  price_cents: number;  stock: number;  image_url: string | null;  active: boolean;};type Row = {  id: string;  name: string;  description: string;  price_cents: number;  stock: number;  image_key: string | null;  active: number;};const COLUMNS = "id, name, description, price_cents, stock, image_key, active";const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;export function toProduct(r: Row, images: ImageUrls): Product {  return {    id: r.id,    name: r.name,    description: r.description,    price_cents: r.price_cents,    stock: r.stock,    image_url: r.image_key ? images(r.image_key) : null,    active: r.active === 1,  };}export const DEFAULT_PAGE = 24;export const MAX_PAGE = 100;export type Page = { products: Product[]; next: string | null };// Active products by name, a page at a time. The cursor is the (name, id) of the last product on// the previous page, so pages stay correct while products are added or retired, and the query// walks the products_by_name index instead of skipping rows with OFFSET.export async function listProducts(db: D1Database, images: ImageUrls, limit = DEFAULT_PAGE, after: string | null = null): Promise<Page> {  const from = after === null ? null : decodeCursor(after);  const { results } = await db    .prepare(      `SELECT ${COLUMNS} FROM products       WHERE active = 1 AND (?1 IS NULL OR (name, id) > (?1, ?2))       ORDER BY name, id LIMIT ?3`,    )    .bind(from?.[0] ?? null, from?.[1] ?? null, limit + 1)    .all<Row>();  const page = results.slice(0, limit);  const last = page[page.length - 1];  return { products: page.map((r) => toProduct(r, images)), next: results.length > limit && last ? encodeCursor(last.name, last.id) : null };}export function parseLimit(value: string | null): number {  if (value === null) return DEFAULT_PAGE;  const n = Number(value);  if (!Number.isInteger(n) || n < 1 || n > MAX_PAGE) throw new HttpError(400, "bad_limit", `limit is a whole number from 1 to ${MAX_PAGE}.`);  return n;}function encodeCursor(name: string, id: string): string {  const bytes = new TextEncoder().encode(JSON.stringify([name, id]));  return btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");}function decodeCursor(cursor: string): [string, string] {  try {    const bin = atob(cursor.replace(/-/g, "+").replace(/_/g, "/"));    const v: unknown = JSON.parse(new TextDecoder().decode(Uint8Array.from(bin, (c) => c.charCodeAt(0))));    if (Array.isArray(v) && v.length === 2 && typeof v[0] === "string" && typeof v[1] === "string") return [v[0], v[1]];  } catch {    // fall through to the error below  }  throw new HttpError(400, "bad_cursor", "after is a cursor from a previous page's next.");}// Any product, active or not. Shopper routes check `active` themselves.export async function getProduct(db: D1Database, id: string, images: ImageUrls): Promise<Product | null> {  const row = await db.prepare(`SELECT ${COLUMNS} FROM products WHERE id = ?`).bind(id).first<Row>();  return row ? toProduct(row, images) : null;}// The active products among `ids`, in the order given. Ids that are unknown or inactive are left// out. Search uses it to turn index hits into products with their current price and stock.export async function activeProducts(db: D1Database, ids: string[], images: ImageUrls): Promise<Product[]> {  if (!ids.length) return [];  const { results } = await db    .prepare(`SELECT ${COLUMNS} FROM products WHERE active = 1 AND id IN (SELECT value FROM json_each(?))`)    .bind(JSON.stringify(ids))    .all<Row>();  const byId = new Map(results.map((r) => [r.id, r]));  return ids.flatMap((id) => {    const row = byId.get(id);    return row ? [toProduct(row, images)] : [];  });}export type ProductInput = {  id: string;  name: string;  description: string;  price_cents: number;  stock: number;  active: boolean;};export function parseProductInput(id: string, body: Record<string, unknown>): ProductInput {  if (!SLUG.test(id) || id.length > 64) {    throw new HttpError(400, "bad_id", "A product id is a lowercase slug of at most 64 characters.");  }  const { name, description = "", price_cents, stock = 0, active = true } = body;  if (typeof name !== "string" || !name.trim() || name.length > 120) {    throw new HttpError(400, "bad_name", "name is a string of 1 to 120 characters.");  }  if (typeof description !== "string" || description.length > 2000) {    throw new HttpError(400, "bad_description", "description is a string of at most 2,000 characters.");  }  if (!Number.isInteger(price_cents) || (price_cents as number) <= 0) {    throw new HttpError(400, "bad_price", "price_cents is a whole number of cents above zero.");  }  if (!Number.isInteger(stock) || (stock as number) < 0) {    throw new HttpError(400, "bad_stock", "stock is a whole number, zero or more.");  }  if (typeof active !== "boolean") throw new HttpError(400, "bad_active", "active is true or false.");  return { id, name: name.trim(), description, price_cents: price_cents as number, stock: stock as number, active };}export async function upsertProduct(db: D1Database, p: ProductInput, now: number, images: ImageUrls): Promise<Product> {  await db    .prepare(      `INSERT INTO products (id, name, description, price_cents, stock, active, created_at, updated_at)       VALUES (?, ?, ?, ?, ?, ?, ?, ?)       ON CONFLICT (id) DO UPDATE SET         name = excluded.name, description = excluded.description, price_cents = excluded.price_cents,         stock = excluded.stock, active = excluded.active, updated_at = excluded.updated_at`,    )    .bind(p.id, p.name, p.description, p.price_cents, p.stock, p.active ? 1 : 0, now, now)    .run();  return (await getProduct(db, p.id, images))!;}