clone

push a directory of html from the terminal and get a four-character url back. a deno cli on cloudflare

codex/agent-interface
nzip / worker / src / db.ts
db.ts 6.8K
1import {2 formatAddress,3 SITES_PER_VAULT,4 type Target,5 VAULT_SLOTS,6 vaultSlotOf,7} from "../../shared/mod.ts";8import type { PushInfo, SiteDetail, SiteInfo, VaultInfo } from "../../shared/mod.ts";9import { type Env, siteUrl } from "./env.ts";1011export const HISTORY_CAP = 10;1213export interface SiteRow {14 address: number;15 vault_slot: number;16 alias: string | null;17 current_manifest: string;18 created_at: number;19 updated_at: number;20 expires_at: number | null;21 password_hash: string | null;22}2324export interface VaultRow {25 slot: number;26 name: string;27 created_at: number;28}2930export async function getSiteByAddress(env: Env, address: number): Promise<SiteRow | null> {31 return await env.DB.prepare("SELECT * FROM sites WHERE address = ?")32 .bind(address).first<SiteRow>();33}3435export async function getVaultByName(env: Env, name: string): Promise<VaultRow | null> {36 return await env.DB.prepare("SELECT * FROM vaults WHERE name = ?").bind(name).first<VaultRow>();37}3839export async function getVaultBySlot(env: Env, slot: number): Promise<VaultRow | null> {40 return await env.DB.prepare("SELECT * FROM vaults WHERE slot = ?").bind(slot).first<VaultRow>();41}4243/** Resolve an API target to an existing site row, or null if it doesn't exist. */44export async function resolveTarget(env: Env, target: Target): Promise<SiteRow | null> {45 if ("address" in target) return await getSiteByAddress(env, target.address);46 const vault = await getVaultByName(env, target.vault);47 if (!vault) return null;48 return await env.DB.prepare("SELECT * FROM sites WHERE vault_slot = ? AND alias = ?")49 .bind(vault.slot, target.alias).first<SiteRow>();50}5152/**53 * Pick a random free address within a vault. Random (not sequential) so URLs54 * don't leak site count or enumerate in order.55 */56export async function allocateAddress(env: Env, slot: number): Promise<number> {57 const taken = await env.DB.prepare("SELECT address FROM sites WHERE vault_slot = ?")58 .bind(slot).all<{ address: number }>();59 const used = new Set(taken.results.map((r) => r.address));60 if (used.size >= SITES_PER_VAULT) throw new Error("vault is full");61 for (let i = 0; i < 64; i++) {62 const candidate = (slot << 12) | (crypto.getRandomValues(new Uint32Array(1))[0] & 0xfff);63 if (!used.has(candidate)) return candidate;64 }65 // Vault nearly full and randomness unlucky: fall back to first free slot.66 for (let id = 0; id < SITES_PER_VAULT; id++) {67 const candidate = (slot << 12) | id;68 if (!used.has(candidate)) return candidate;69 }70 throw new Error("vault is full");71}7273export interface CommitParams {74 address: number;75 vaultSlot: number;76 alias: string | null;77 manifestHash: string;78 expiresAt: number | null;79 isNew: boolean;80 note?: string;81}8283/** Atomic commit: upsert site, append history, prune beyond HISTORY_CAP. Returns new seq. */84export async function commitSite(env: Env, p: CommitParams): Promise<number> {85 const now = Math.floor(Date.now() / 1000);86 const seqRow = await env.DB.prepare("SELECT COALESCE(MAX(seq), 0) AS s FROM pushes WHERE address = ?")87 .bind(p.address).first<{ s: number }>();88 const seq = (seqRow?.s ?? 0) + 1;8990 const statements = [91 p.isNew92 ? env.DB.prepare(93 `INSERT INTO sites (address, vault_slot, alias, current_manifest, created_at, updated_at, expires_at)94 VALUES (?, ?, ?, ?, ?, ?, ?)`,95 ).bind(p.address, p.vaultSlot, p.alias, p.manifestHash, now, now, p.expiresAt)96 : env.DB.prepare(97 `UPDATE sites SET current_manifest = ?, updated_at = ?, expires_at = ?, alias = COALESCE(?, alias)98 WHERE address = ?`,99 ).bind(p.manifestHash, now, p.expiresAt, p.alias, p.address),100 env.DB.prepare(101 "INSERT INTO pushes (address, seq, manifest_hash, pushed_at, note) VALUES (?, ?, ?, ?, ?)",102 ).bind(p.address, seq, p.manifestHash, now, p.note ?? null),103 env.DB.prepare(104 "DELETE FROM pushes WHERE address = ? AND seq <= ?",105 ).bind(p.address, seq - HISTORY_CAP),106 ];107 await env.DB.batch(statements);108 return seq;109}110111export async function listSites(env: Env, vaultSlot?: number): Promise<(SiteRow & { vault_name: string })[]> {112 const base =113 `SELECT s.*, v.name AS vault_name FROM sites s JOIN vaults v ON v.slot = s.vault_slot`;114 const stmt = vaultSlot === undefined115 ? env.DB.prepare(`${base} ORDER BY s.updated_at DESC`)116 : env.DB.prepare(`${base} WHERE s.vault_slot = ? ORDER BY s.updated_at DESC`).bind(vaultSlot);117 const res = await stmt.all<SiteRow & { vault_name: string }>();118 return res.results;119}120121export async function siteHistory(env: Env, address: number): Promise<PushInfo[]> {122 const res = await env.DB.prepare(123 "SELECT seq, manifest_hash, pushed_at, note FROM pushes WHERE address = ? ORDER BY seq DESC",124 ).bind(address).all<{ seq: number; manifest_hash: string; pushed_at: number; note: string | null }>();125 return res.results.map((r) => ({126 seq: r.seq,127 manifestHash: r.manifest_hash,128 pushedAt: r.pushed_at,129 note: r.note,130 }));131}132133export async function listVaults(env: Env): Promise<VaultInfo[]> {134 const res = await env.DB.prepare(135 `SELECT v.slot, v.name, v.created_at, COUNT(s.address) AS site_count136 FROM vaults v LEFT JOIN sites s ON s.vault_slot = v.slot137 GROUP BY v.slot ORDER BY v.slot`,138 ).all<{ slot: number; name: string; created_at: number; site_count: number }>();139 return res.results.map((r) => ({140 slot: r.slot,141 name: r.name,142 createdAt: r.created_at,143 siteCount: r.site_count,144 }));145}146147export async function createVault(env: Env, name: string, slot?: number): Promise<VaultRow> {148 const now = Math.floor(Date.now() / 1000);149 let chosen = slot;150 if (chosen === undefined) {151 const takenRes = await env.DB.prepare("SELECT slot FROM vaults").all<{ slot: number }>();152 const taken = new Set(takenRes.results.map((r) => r.slot));153 for (let s = 0; s < VAULT_SLOTS; s++) {154 if (!taken.has(s)) {155 chosen = s;156 break;157 }158 }159 if (chosen === undefined) throw new Error("all 16 vault slots are taken");160 }161 await env.DB.prepare("INSERT INTO vaults (slot, name, created_at) VALUES (?, ?, ?)")162 .bind(chosen, name, now).run();163 return { slot: chosen, name, created_at: now };164}165166export function siteRowToInfo(env: Env, row: SiteRow, vaultName: string): SiteInfo {167 const address = formatAddress(row.address);168 return {169 address,170 vault: vaultName,171 alias: row.alias,172 manifestHash: row.current_manifest,173 createdAt: row.created_at,174 updatedAt: row.updated_at,175 expiresAt: row.expires_at,176 url: siteUrl(env, address),177 protected: row.password_hash !== null,178 };179}180181export async function siteRowToDetail(env: Env, row: SiteRow): Promise<SiteDetail> {182 const vault = await getVaultBySlot(env, vaultSlotOf(row.address));183 return {184 ...siteRowToInfo(env, row, vault?.name ?? `slot-${row.vault_slot}`),185 history: await siteHistory(env, row.address),186 };187}