v1
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
|
||||
export const db = new Database("spark_slop.db", { create: true });
|
||||
|
||||
// Use WAL for better concurrent read/write performance.
|
||||
db.exec("PRAGMA journal_mode = WAL;");
|
||||
db.exec("PRAGMA foreign_keys = ON;");
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS ideas (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS notes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
idea_id INTEGER NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (idea_id) REFERENCES ideas(id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_notes_idea_id ON notes(idea_id);`);
|
||||
|
||||
export interface Idea {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
note_count?: number;
|
||||
}
|
||||
|
||||
export interface Note {
|
||||
id: number;
|
||||
idea_id: number;
|
||||
body: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// --- Idea queries ---
|
||||
|
||||
export function listIdeas(): Idea[] {
|
||||
return db
|
||||
.query(
|
||||
`SELECT i.*, COUNT(n.id) AS note_count
|
||||
FROM ideas i
|
||||
LEFT JOIN notes n ON n.idea_id = i.id
|
||||
GROUP BY i.id
|
||||
ORDER BY i.updated_at DESC`
|
||||
)
|
||||
.all() as Idea[];
|
||||
}
|
||||
|
||||
export function getIdea(id: number): Idea | null {
|
||||
return (db.query(`SELECT * FROM ideas WHERE id = ?`).get(id) as Idea) ?? null;
|
||||
}
|
||||
|
||||
export function createIdea(title: string, description: string): Idea {
|
||||
const row = db
|
||||
.query(
|
||||
`INSERT INTO ideas (title, description) VALUES (?, ?) RETURNING *`
|
||||
)
|
||||
.get(title, description) as Idea;
|
||||
return row;
|
||||
}
|
||||
|
||||
export function updateIdea(
|
||||
id: number,
|
||||
title: string,
|
||||
description: string
|
||||
): Idea | null {
|
||||
return (
|
||||
(db
|
||||
.query(
|
||||
`UPDATE ideas
|
||||
SET title = ?, description = ?, updated_at = datetime('now')
|
||||
WHERE id = ?
|
||||
RETURNING *`
|
||||
)
|
||||
.get(title, description, id) as Idea) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteIdea(id: number): boolean {
|
||||
const res = db.query(`DELETE FROM ideas WHERE id = ?`).run(id);
|
||||
return res.changes > 0;
|
||||
}
|
||||
|
||||
// --- Note queries ---
|
||||
|
||||
export function listNotes(ideaId: number): Note[] {
|
||||
return db
|
||||
.query(`SELECT * FROM notes WHERE idea_id = ? ORDER BY created_at ASC, id ASC`)
|
||||
.all(ideaId) as Note[];
|
||||
}
|
||||
|
||||
export function createNote(ideaId: number, body: string): Note {
|
||||
const note = db
|
||||
.query(`INSERT INTO notes (idea_id, body) VALUES (?, ?) RETURNING *`)
|
||||
.get(ideaId, body) as Note;
|
||||
// Touch the parent idea so it sorts to the top.
|
||||
db.query(`UPDATE ideas SET updated_at = datetime('now') WHERE id = ?`).run(
|
||||
ideaId
|
||||
);
|
||||
return note;
|
||||
}
|
||||
|
||||
export function deleteNote(id: number): boolean {
|
||||
const res = db.query(`DELETE FROM notes WHERE id = ?`).run(id);
|
||||
return res.changes > 0;
|
||||
}
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
import {
|
||||
listIdeas,
|
||||
getIdea,
|
||||
createIdea,
|
||||
updateIdea,
|
||||
deleteIdea,
|
||||
listNotes,
|
||||
createNote,
|
||||
deleteNote,
|
||||
} from "./db";
|
||||
|
||||
const PORT = Number(process.env.PORT ?? 3000);
|
||||
const PUBLIC_DIR = new URL("../public/", import.meta.url).pathname;
|
||||
|
||||
function json(data: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function err(message: string, status = 400): Response {
|
||||
return json({ error: message }, status);
|
||||
}
|
||||
|
||||
async function readJson(req: Request): Promise<any> {
|
||||
try {
|
||||
return await req.json();
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function serveStatic(pathname: string): Promise<Response> {
|
||||
// Map "/" -> index.html, "/app" -> app.html, otherwise the literal file.
|
||||
let rel = pathname === "/" ? "index.html" : pathname.slice(1);
|
||||
if (rel === "app") rel = "app.html";
|
||||
|
||||
let file = Bun.file(PUBLIC_DIR + rel);
|
||||
if (!(await file.exists())) {
|
||||
// SPA-ish fallback for unknown non-asset routes.
|
||||
if (!rel.includes(".")) file = Bun.file(PUBLIC_DIR + "index.html");
|
||||
}
|
||||
if (!(await file.exists())) return new Response("Not found", { status: 404 });
|
||||
return new Response(file);
|
||||
}
|
||||
|
||||
const server = Bun.serve({
|
||||
port: PORT,
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
const { pathname } = url;
|
||||
|
||||
// ---------- API ----------
|
||||
if (pathname.startsWith("/api/")) {
|
||||
try {
|
||||
return await handleApi(req, pathname);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return err("Internal server error", 500);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Static ----------
|
||||
return serveStatic(pathname);
|
||||
},
|
||||
});
|
||||
|
||||
async function handleApi(req: Request, pathname: string): Promise<Response> {
|
||||
const method = req.method;
|
||||
|
||||
// /api/ideas
|
||||
if (pathname === "/api/ideas") {
|
||||
if (method === "GET") return json(listIdeas());
|
||||
if (method === "POST") {
|
||||
const { title, description } = await readJson(req);
|
||||
if (!title || typeof title !== "string" || !title.trim())
|
||||
return err("Title is required");
|
||||
return json(
|
||||
createIdea(title.trim(), (description ?? "").toString().trim()),
|
||||
201
|
||||
);
|
||||
}
|
||||
return err("Method not allowed", 405);
|
||||
}
|
||||
|
||||
// /api/ideas/:id
|
||||
let m = pathname.match(/^\/api\/ideas\/(\d+)$/);
|
||||
if (m) {
|
||||
const id = Number(m[1]);
|
||||
if (method === "GET") {
|
||||
const idea = getIdea(id);
|
||||
return idea ? json(idea) : err("Idea not found", 404);
|
||||
}
|
||||
if (method === "PUT") {
|
||||
const { title, description } = await readJson(req);
|
||||
if (!title || !title.trim()) return err("Title is required");
|
||||
const updated = updateIdea(
|
||||
id,
|
||||
title.trim(),
|
||||
(description ?? "").toString().trim()
|
||||
);
|
||||
return updated ? json(updated) : err("Idea not found", 404);
|
||||
}
|
||||
if (method === "DELETE") {
|
||||
return deleteIdea(id) ? json({ ok: true }) : err("Idea not found", 404);
|
||||
}
|
||||
return err("Method not allowed", 405);
|
||||
}
|
||||
|
||||
// /api/ideas/:id/notes
|
||||
m = pathname.match(/^\/api\/ideas\/(\d+)\/notes$/);
|
||||
if (m) {
|
||||
const ideaId = Number(m[1]);
|
||||
if (!getIdea(ideaId)) return err("Idea not found", 404);
|
||||
if (method === "GET") return json(listNotes(ideaId));
|
||||
if (method === "POST") {
|
||||
const { body } = await readJson(req);
|
||||
if (!body || !body.toString().trim()) return err("Note body is required");
|
||||
return json(createNote(ideaId, body.toString().trim()), 201);
|
||||
}
|
||||
return err("Method not allowed", 405);
|
||||
}
|
||||
|
||||
// /api/notes/:id
|
||||
m = pathname.match(/^\/api\/notes\/(\d+)$/);
|
||||
if (m) {
|
||||
const id = Number(m[1]);
|
||||
if (method === "DELETE") {
|
||||
return deleteNote(id) ? json({ ok: true }) : err("Note not found", 404);
|
||||
}
|
||||
return err("Method not allowed", 405);
|
||||
}
|
||||
|
||||
return err("Not found", 404);
|
||||
}
|
||||
|
||||
console.log(`✨ Spark Slop running at http://localhost:${server.port}`);
|
||||
Reference in New Issue
Block a user