fix(quotesdb): fix D1 exec() newline truncation in migrations, add justfile and migrate command

D1's exec() treats newlines as statement separators, causing multiline
CREATE TABLE statements to be truncated after the first line and return
"incomplete input: SQLITE_ERROR" on every request.

Fix: switch run_migrations() in D1Repository to use prepare(sql).run()
instead of exec(sql), which treats the full string as a single statement.

Also moves db and handlers modules from src/bin/api/ to src/ (library
modules), adds justfile with build/deploy/migrate recipes, adds
migrations/schema.sql for direct wrangler d1 execute usage, and adds
wrangler.toml for worker deployment configuration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
quotesdb
Elijah Voigt 3 months ago
parent 66cbe67100
commit b00f24ae85

@ -0,0 +1,30 @@
build: build-ui build-api
migrate:
# Apply schema migrations to the remote D1 database.
# The ALTER TABLE at the end will error if hidden already exists — that's expected.
wrangler d1 execute quotesdb --remote --file migrations/schema.sql
build-api:
# worker-build runs wasm-bindgen to produce ES-module-compatible output in build/worker/.
# Install once with: cargo install worker-build
# --features workers-api enables D1 bindings and route handlers for the Workers runtime.
worker-build --release -- --features workers-api
build-ui:
trunk build --release
deploy: deploy-ui deploy-api
deploy-ui:
# --commit-dirty=true silences the git-dirty warning (expected during local deploys).
wrangler pages deploy dist --project-name quotesdb-ui --branch quotesdb --commit-dirty=true
deploy-api:
wrangler deploy --config wrangler.toml
run-api:
cargo run --features workers-api
run-ui:
trunk serve

@ -0,0 +1,40 @@
-- quotesdb D1 schema migrations
-- Run with: wrangler d1 execute quotesdb --remote --file migrations/schema.sql
-- All CREATE statements are idempotent (IF NOT EXISTS).
-- The ALTER TABLE is not idempotent; errors are expected on re-runs (column already exists).
CREATE TABLE IF NOT EXISTS quotes (
id TEXT PRIMARY KEY,
text TEXT NOT NULL,
author TEXT NOT NULL,
source TEXT,
date TEXT,
auth_code TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS quote_tags (
quote_id TEXT NOT NULL REFERENCES quotes(id) ON DELETE CASCADE,
tag TEXT NOT NULL,
PRIMARY KEY (quote_id, tag)
);
CREATE INDEX IF NOT EXISTS idx_quote_tags_quote_id ON quote_tags(quote_id);
CREATE INDEX IF NOT EXISTS idx_quotes_author ON quotes(author COLLATE NOCASE);
CREATE TABLE IF NOT EXISTS admin_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS reports (
id TEXT PRIMARY KEY,
quote_id TEXT NOT NULL REFERENCES quotes(id) ON DELETE CASCADE,
reason TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Add hidden column to existing databases (ignore error if column already exists).
ALTER TABLE quotes ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0;

@ -0,0 +1,47 @@
//! Database connection setup for the native API server.
//!
//! Provides [`open`] which opens a `tokio-rusqlite` connection, configures
//! SQLite pragmas for WAL mode and foreign key enforcement, and wraps the
//! result in a [`NativeRepository`].
use super::{DbError, NativeRepository};
use tokio_rusqlite::Connection;
/// Open a SQLite database at `path` and return a configured [`NativeRepository`].
///
/// This function:
/// 1. Opens the file-backed SQLite connection via `tokio_rusqlite::Connection::open`.
/// 2. Enables Write-Ahead Logging (`PRAGMA journal_mode=WAL`) for better
/// concurrent read performance.
/// 3. Enables foreign key enforcement (`PRAGMA foreign_keys=ON`) so that
/// `ON DELETE CASCADE` works on the `quote_tags` table.
///
/// Returns `Err(DbError::Internal(...))` if the file cannot be opened or if
/// the pragma commands fail.
///
/// # Examples
///
/// ```no_run
/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
/// use quotesdb::db::QuoteRepository as _;
/// let repo = quotesdb::db::connection::open("quotesdb.sqlite").await?;
/// repo.run_migrations().await?;
/// # Ok(())
/// # }
/// ```
pub async fn open(path: &str) -> Result<NativeRepository, DbError> {
let conn = Connection::open(path)
.await
.map_err(|e| DbError::Internal(format!("failed to open database: {e}")))?;
// Configure SQLite pragmas on the connection thread
conn.call(|c| {
// WAL mode improves concurrent reader throughput
c.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")?;
Ok(())
})
.await
.map_err(|e| DbError::Internal(format!("pragma configuration failed: {e}")))?;
Ok(NativeRepository::new(conn))
}

@ -0,0 +1,947 @@
//! Cloudflare D1 repository implementation (wasm32 only).
//!
//! [`D1Repository`] wraps a [`worker::d1::Database`] and provides full
//! implementations of all [`super::QuoteRepository`] methods using the
//! Cloudflare D1 API from workers-rs 0.5.
//!
//! This module is only compiled for `wasm32-unknown-unknown` targets.
use super::{DbError, DeleteResult, ListResult, QuoteRepository};
use crate::{generate_auth_code, generate_id, CreateQuoteInput, Quote, UpdateQuoteInput};
use wasm_bindgen::JsValue;
// ── Helper structs ────────────────────────────────────────────────────────────
/// Row shape for quote SELECT queries.
#[derive(Debug, serde::Deserialize)]
struct QuoteRow {
id: String,
text: String,
author: String,
source: Option<String>,
date: Option<String>,
/// Stored as an integer (0 = visible, 1 = hidden); converted to bool on deserialization.
hidden: i64,
created_at: String,
updated_at: String,
}
impl QuoteRow {
/// Convert this row into a [`Quote`] by attaching a pre-fetched tags list.
fn into_quote(self, tags: Vec<String>) -> Quote {
Quote {
id: self.id,
text: self.text,
author: self.author,
source: self.source,
date: self.date,
hidden: self.hidden != 0,
created_at: self.created_at,
updated_at: self.updated_at,
tags,
}
}
}
/// Row shape for auth_code lookups.
#[derive(Debug, serde::Deserialize)]
struct AuthRow {
auth_code: String,
}
/// Row shape for tag lookups.
#[derive(Debug, serde::Deserialize)]
struct TagRow {
tag: String,
}
/// Row shape for COUNT(*) queries.
#[derive(Debug, serde::Deserialize)]
struct CountRow {
count: u32,
}
// ── Repository struct ─────────────────────────────────────────────────────────
/// Cloudflare D1-backed repository (wasm32 only).
///
/// Wraps a [`worker::d1::D1Database`] handle provided by the Workers runtime.
/// All methods use the D1 prepared-statement API to execute SQL queries.
pub struct D1Repository {
/// The Cloudflare D1 database handle.
pub db: worker::d1::D1Database,
}
// SAFETY: wasm32-unknown-unknown is single-threaded; JS values are never sent
// across threads. Required to satisfy Arc<dyn QuoteRepository + Send + Sync>.
unsafe impl Send for D1Repository {}
unsafe impl Sync for D1Repository {}
impl D1Repository {
/// Create a new [`D1Repository`] wrapping the given D1 database handle.
pub fn new(db: worker::d1::D1Database) -> Self {
Self { db }
}
/// Fetch all tags for a quote, sorted alphabetically.
///
/// Returns a sorted `Vec<String>` of tag values, or an empty vec if none exist.
async fn fetch_tags(&self, id: &str) -> Result<Vec<String>, DbError> {
let rows = self
.db
.prepare("SELECT tag FROM quote_tags WHERE quote_id = ?1 ORDER BY tag")
.bind(&[JsValue::from_str(id)])
.map_err(|e| DbError::Internal(e.to_string()))?
.all()
.await
.map_err(|e| DbError::Internal(e.to_string()))?
.results::<TagRow>()
.map_err(|e| DbError::Internal(e.to_string()))?;
Ok(rows.into_iter().map(|r| r.tag).collect())
}
}
// ── QuoteRepository impl ──────────────────────────────────────────────────────
#[async_trait::async_trait(?Send)]
impl QuoteRepository for D1Repository {
/// Run all DDL migration statements from [`super::migrations`].
///
/// Covers `quotes`, `quote_tags`, tag index, author index, `admin_config`,
/// and the `hidden` column ALTER. Safe to call multiple times — CREATE
/// statements use `IF NOT EXISTS`. The ALTER TABLE error (column already
/// exists) is intentionally ignored for idempotency.
///
/// Uses `prepare(sql).run()` instead of `exec(sql)` because D1's `exec()`
/// treats newlines as statement separators, which truncates multiline DDL
/// statements. `prepare().run()` treats the entire string as one statement.
async fn run_migrations(&self) -> Result<(), DbError> {
use super::migrations::*;
for sql in &[
CREATE_QUOTES,
CREATE_QUOTE_TAGS,
CREATE_TAG_INDEX,
CREATE_AUTHOR_INDEX,
CREATE_ADMIN_CONFIG,
CREATE_REPORTS,
] {
self.db
.prepare(*sql)
.run()
.await
.map_err(|e| DbError::Internal(e.to_string()))?;
}
// ALTER TABLE does not support IF NOT EXISTS — ignore the error when
// the column already exists (idempotent on re-runs).
let _ = self.db.prepare(ALTER_QUOTES_ADD_HIDDEN).run().await; // ignore "column exists" error
Ok(())
}
/// List quotes with optional author/tag/date filters and 1-based pagination.
///
/// Page size is fixed at 10. Uses `COLLATE NOCASE` for author filtering.
/// `date_after` and `date_before` are ISO date prefix strings compared via
/// `>=` / `<=` against the stored `date` column; rows where `date IS NULL`
/// are excluded when either bound is set.
/// Tags for each returned quote are fetched in a second query per quote to
/// avoid duplicate rows from a JOIN.
async fn list_quotes(
&self,
page: u32,
author: Option<&str>,
tag: Option<&str>,
date_after: Option<&str>,
date_before: Option<&str>,
) -> Result<ListResult, DbError> {
const PAGE_SIZE: u32 = 10;
let page = page.max(1);
// ── Build WHERE clause with positional params ──────────────────────
// Always exclude hidden quotes from listing endpoints.
let mut conditions: Vec<String> = vec!["q.hidden = 0".to_owned()];
let mut binds: Vec<JsValue> = Vec::new();
let mut param_idx: u32 = 1;
if let Some(a) = author {
conditions.push(format!("q.author = ?{param_idx} COLLATE NOCASE"));
binds.push(JsValue::from_str(a));
param_idx += 1;
}
if let Some(t) = tag {
conditions.push(format!(
"q.id IN (SELECT quote_id FROM quote_tags WHERE tag = ?{param_idx})"
));
binds.push(JsValue::from_str(t));
param_idx += 1;
}
// Exclude NULL dates when any date bound is active
if date_after.is_some() || date_before.is_some() {
conditions.push("q.date IS NOT NULL".to_owned());
}
if let Some(da) = date_after {
conditions.push(format!("q.date >= ?{param_idx}"));
binds.push(JsValue::from_str(da));
param_idx += 1;
}
if let Some(db) = date_before {
conditions.push(format!("q.date <= ?{param_idx}"));
binds.push(JsValue::from_str(db));
param_idx += 1;
}
let where_clause = format!("WHERE {}", conditions.join(" AND "));
// ── Count total matching rows ──────────────────────────────────────
let count_sql = format!("SELECT COUNT(*) as count FROM quotes q {where_clause}");
let count_row = self
.db
.prepare(&count_sql)
.bind(&binds)
.map_err(|e| DbError::Internal(e.to_string()))?
.first::<CountRow>(None)
.await
.map_err(|e| DbError::Internal(e.to_string()))?
.ok_or_else(|| DbError::Internal("count query returned no row".to_string()))?;
let total_count = count_row.count;
let total_pages = ((total_count + PAGE_SIZE - 1) / PAGE_SIZE).max(1);
let offset = (page - 1) * PAGE_SIZE;
// ── Fetch the page of quotes ───────────────────────────────────────
let list_sql = format!(
"SELECT q.id, q.text, q.author, q.source, q.date, \
q.hidden, q.created_at, q.updated_at \
FROM quotes q {where_clause} \
ORDER BY q.created_at DESC \
LIMIT ?{param_idx} OFFSET ?{}",
param_idx + 1
);
let mut list_binds = binds.clone();
list_binds.push(JsValue::from_f64(PAGE_SIZE as f64));
list_binds.push(JsValue::from_f64(offset as f64));
let rows = self
.db
.prepare(&list_sql)
.bind(&list_binds)
.map_err(|e| DbError::Internal(e.to_string()))?
.all()
.await
.map_err(|e| DbError::Internal(e.to_string()))?
.results::<QuoteRow>()
.map_err(|e| DbError::Internal(e.to_string()))?;
// Second pass: fetch tags for each quote
let mut quotes: Vec<Quote> = Vec::with_capacity(rows.len());
for row in rows {
let tags = self.fetch_tags(&row.id).await?;
quotes.push(row.into_quote(tags));
}
Ok(ListResult {
quotes,
page,
total_pages,
total_count,
})
}
/// Retrieve a single quote by its primary key.
///
/// Returns `Ok(None)` when no row matches `id`.
async fn get_quote(&self, id: &str) -> Result<Option<Quote>, DbError> {
let row = self
.db
.prepare(
"SELECT id, text, author, source, date, hidden, created_at, updated_at \
FROM quotes WHERE id = ?1",
)
.bind(&[JsValue::from_str(id)])
.map_err(|e| DbError::Internal(e.to_string()))?
.first::<QuoteRow>(None)
.await
.map_err(|e| DbError::Internal(e.to_string()))?;
match row {
None => Ok(None),
Some(r) => {
let tags = self.fetch_tags(&r.id).await?;
Ok(Some(r.into_quote(tags)))
}
}
}
/// Return one quote chosen at random.
///
/// Returns `Ok(None)` when the `quotes` table is empty.
async fn get_random_quote(&self) -> Result<Option<Quote>, DbError> {
let row = self
.db
.prepare(
"SELECT id, text, author, source, date, hidden, created_at, updated_at \
FROM quotes WHERE hidden = 0 ORDER BY RANDOM() LIMIT 1",
)
.first::<QuoteRow>(None)
.await
.map_err(|e| DbError::Internal(e.to_string()))?;
match row {
None => Ok(None),
Some(r) => {
let tags = self.fetch_tags(&r.id).await?;
Ok(Some(r.into_quote(tags)))
}
}
}
/// Insert a new quote row and its associated tags.
///
/// If `input.auth_code` is `None`, a 4-word passphrase is generated.
/// Returns the persisted [`Quote`] (without `auth_code`) and the raw
/// auth-code string so the caller can include it in the creation response.
async fn create_quote(&self, input: CreateQuoteInput) -> Result<(Quote, String), DbError> {
let id = generate_id();
let auth_code = input.auth_code.unwrap_or_else(generate_auth_code);
// Insert the quote row
self.db
.prepare(
"INSERT INTO quotes (id, text, author, source, date, auth_code) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
)
.bind(&[
JsValue::from_str(&id),
JsValue::from_str(&input.text),
JsValue::from_str(&input.author),
input
.source
.as_deref()
.map(JsValue::from_str)
.unwrap_or(JsValue::NULL),
input
.date
.as_deref()
.map(JsValue::from_str)
.unwrap_or(JsValue::NULL),
JsValue::from_str(&auth_code),
])
.map_err(|e| DbError::Internal(e.to_string()))?
.run()
.await
.map_err(|e| DbError::Internal(e.to_string()))?;
// Batch insert tags
if !input.tags.is_empty() {
let tag_stmts: Vec<worker::d1::D1PreparedStatement> = input
.tags
.iter()
.map(|tag| {
self.db
.prepare("INSERT OR IGNORE INTO quote_tags (quote_id, tag) VALUES (?1, ?2)")
.bind(&[JsValue::from_str(&id), JsValue::from_str(tag)])
.map_err(|e| DbError::Internal(e.to_string()))
})
.collect::<Result<Vec<_>, _>>()?;
self.db
.batch(tag_stmts)
.await
.map_err(|e| DbError::Internal(e.to_string()))?;
}
// Read back the row to get server-generated timestamps
let row = self
.db
.prepare(
"SELECT id, text, author, source, date, hidden, created_at, updated_at \
FROM quotes WHERE id = ?1",
)
.bind(&[JsValue::from_str(&id)])
.map_err(|e| DbError::Internal(e.to_string()))?
.first::<QuoteRow>(None)
.await
.map_err(|e| DbError::Internal(e.to_string()))?
.ok_or_else(|| DbError::Internal("inserted row not found on read-back".to_string()))?;
let tags = self.fetch_tags(&id).await?;
Ok((row.into_quote(tags), auth_code))
}
/// Update non-`None` fields on an existing quote.
///
/// Verifies `auth_code` before making any changes. If `input.tags` is
/// `Some`, the entire tag set is replaced. Updates `updated_at` to the
/// current UTC time.
async fn update_quote(
&self,
id: &str,
input: UpdateQuoteInput,
auth_code: &str,
) -> Result<Quote, DbError> {
// Phase 1: fetch stored auth_code
let auth_row = self
.db
.prepare("SELECT auth_code FROM quotes WHERE id = ?1")
.bind(&[JsValue::from_str(id)])
.map_err(|e| DbError::Internal(e.to_string()))?
.first::<AuthRow>(None)
.await
.map_err(|e| DbError::Internal(e.to_string()))?;
match auth_row {
None => return Err(DbError::NotFound),
Some(ref r) if r.auth_code == auth_code => {} // exact match, proceed
Some(_) => {
// Check admin code fallback
let admin = self.get_admin_auth_code().await?;
if admin.as_deref() != Some(auth_code) {
return Err(DbError::Forbidden);
}
}
}
// Phase 2: build dynamic SET clause with positional params
let mut sets: Vec<String> = Vec::new();
let mut binds: Vec<JsValue> = Vec::new();
let mut param_idx: u32 = 1;
if let Some(ref text) = input.text {
sets.push(format!("text = ?{param_idx}"));
binds.push(JsValue::from_str(text));
param_idx += 1;
}
if let Some(ref author) = input.author {
sets.push(format!("author = ?{param_idx}"));
binds.push(JsValue::from_str(author));
param_idx += 1;
}
// source and date always updated (None clears the field)
sets.push(format!("source = ?{param_idx}"));
binds.push(
input
.source
.as_deref()
.map(JsValue::from_str)
.unwrap_or(JsValue::NULL),
);
param_idx += 1;
sets.push(format!("date = ?{param_idx}"));
binds.push(
input
.date
.as_deref()
.map(JsValue::from_str)
.unwrap_or(JsValue::NULL),
);
param_idx += 1;
// hidden is only updated when explicitly provided
if let Some(h) = input.hidden {
sets.push(format!("hidden = ?{param_idx}"));
binds.push(JsValue::from_f64(if h { 1.0 } else { 0.0 }));
param_idx += 1;
}
sets.push("updated_at = datetime('now')".to_string());
let update_sql = format!(
"UPDATE quotes SET {} WHERE id = ?{param_idx}",
sets.join(", ")
);
binds.push(JsValue::from_str(id));
self.db
.prepare(&update_sql)
.bind(&binds)
.map_err(|e| DbError::Internal(e.to_string()))?
.run()
.await
.map_err(|e| DbError::Internal(e.to_string()))?;
// Phase 3: replace tags if provided
if let Some(ref tags) = input.tags {
self.db
.prepare("DELETE FROM quote_tags WHERE quote_id = ?1")
.bind(&[JsValue::from_str(id)])
.map_err(|e| DbError::Internal(e.to_string()))?
.run()
.await
.map_err(|e| DbError::Internal(e.to_string()))?;
if !tags.is_empty() {
let tag_stmts: Vec<worker::d1::D1PreparedStatement> = tags
.iter()
.map(|tag| {
self.db
.prepare(
"INSERT OR IGNORE INTO quote_tags (quote_id, tag) \
VALUES (?1, ?2)",
)
.bind(&[JsValue::from_str(id), JsValue::from_str(tag)])
.map_err(|e| DbError::Internal(e.to_string()))
})
.collect::<Result<Vec<_>, _>>()?;
self.db
.batch(tag_stmts)
.await
.map_err(|e| DbError::Internal(e.to_string()))?;
}
}
// Phase 4: read back the updated quote
let row = self
.db
.prepare(
"SELECT id, text, author, source, date, hidden, created_at, updated_at \
FROM quotes WHERE id = ?1",
)
.bind(&[JsValue::from_str(id)])
.map_err(|e| DbError::Internal(e.to_string()))?
.first::<QuoteRow>(None)
.await
.map_err(|e| DbError::Internal(e.to_string()))?
.ok_or(DbError::NotFound)?;
let fetched_tags = self.fetch_tags(&row.id).await?;
Ok(row.into_quote(fetched_tags))
}
/// Delete a quote by ID after verifying the auth code.
///
/// Returns [`DeleteResult::NotFound`] if no quote has that ID,
/// [`DeleteResult::Forbidden`] if neither the per-quote auth code nor the
/// admin super auth code matches, or [`DeleteResult::Deleted`] on success.
/// Tags are removed automatically by the `ON DELETE CASCADE` constraint on
/// `quote_tags`.
async fn delete_quote(&self, id: &str, auth_code: &str) -> Result<DeleteResult, DbError> {
// Fetch stored auth_code
let auth_row = self
.db
.prepare("SELECT auth_code FROM quotes WHERE id = ?1")
.bind(&[JsValue::from_str(id)])
.map_err(|e| DbError::Internal(e.to_string()))?
.first::<AuthRow>(None)
.await
.map_err(|e| DbError::Internal(e.to_string()))?;
match auth_row {
None => return Ok(DeleteResult::NotFound),
Some(ref r) if r.auth_code == auth_code => {
// Per-quote auth matches — fall through to delete
}
Some(_) => {
// Check admin code as fallback
let admin = self.get_admin_auth_code().await?;
if admin.as_deref() != Some(auth_code) {
return Ok(DeleteResult::Forbidden);
}
}
}
self.db
.prepare("DELETE FROM quotes WHERE id = ?1")
.bind(&[JsValue::from_str(id)])
.map_err(|e| DbError::Internal(e.to_string()))?
.run()
.await
.map_err(|e| DbError::Internal(e.to_string()))?;
Ok(DeleteResult::Deleted)
}
/// Retrieve the admin super auth code from `admin_config`.
///
/// Returns `Ok(None)` if the admin code has not been seeded yet.
async fn get_admin_auth_code(&self) -> Result<Option<String>, DbError> {
#[derive(serde::Deserialize)]
struct ValueRow {
value: String,
}
self.db
.prepare("SELECT value FROM admin_config WHERE key = 'admin_auth_code'")
.first::<ValueRow>(None)
.await
.map(|opt| opt.map(|r| r.value))
.map_err(|e| DbError::Internal(e.to_string()))
}
/// Insert the admin auth code if not already present.
///
/// Uses `INSERT OR IGNORE` so calling it multiple times is safe.
async fn seed_admin_auth_code(&self, code: &str) -> Result<(), DbError> {
self.db
.prepare(
"INSERT OR IGNORE INTO admin_config (key, value) VALUES ('admin_auth_code', ?1)",
)
.bind(&[JsValue::from_str(code)])
.map_err(|e| DbError::Internal(e.to_string()))?
.run()
.await
.map(|_| ())
.map_err(|e| DbError::Internal(e.to_string()))
}
/// Replace the admin auth code if `current` matches the stored value.
///
/// Generates a fresh 4-word passphrase when `new_code` is `None`.
///
/// The check and update are performed atomically via a single
/// `UPDATE … WHERE key = 'admin_auth_code' AND value = ?current` statement.
/// The result metadata's `changes` count is inspected: if zero rows were
/// affected the stored code either does not exist or did not match `current`,
/// and `Err(DbError::Forbidden)` is returned. If one row was affected the new
/// code is returned.
async fn update_admin_auth_code(
&self,
current: &str,
new_code: Option<&str>,
) -> Result<String, DbError> {
let replacement = new_code
.map(|s| s.to_owned())
.unwrap_or_else(generate_auth_code);
let result = self
.db
.prepare(
"UPDATE admin_config \
SET value = ?1 \
WHERE key = 'admin_auth_code' AND value = ?2",
)
.bind(&[JsValue::from_str(&replacement), JsValue::from_str(current)])
.map_err(|e| DbError::Internal(e.to_string()))?
.run()
.await
.map_err(|e| DbError::Internal(e.to_string()))?;
let changes = result
.meta()
.map_err(|e| DbError::Internal(e.to_string()))?
.and_then(|m| m.changes)
.unwrap_or(0);
if changes == 0 {
return Err(DbError::Forbidden);
}
Ok(replacement)
}
/// Return whether submissions are currently locked.
///
/// Reads the `submissions_locked` key from `admin_config`.
/// Returns `false` if the key has not been seeded yet.
async fn get_submissions_locked(&self) -> Result<bool, DbError> {
#[derive(serde::Deserialize)]
struct ValueRow {
value: String,
}
let row = self
.db
.prepare("SELECT value FROM admin_config WHERE key = 'submissions_locked'")
.first::<ValueRow>(None)
.await
.map_err(|e| DbError::Internal(e.to_string()))?;
Ok(row.map(|r| r.value == "1").unwrap_or(false))
}
/// Persist the submissions lock state.
///
/// Upserts `"1"` (locked) or `"0"` (unlocked) into the `submissions_locked`
/// key in `admin_config`.
async fn set_submissions_locked(&self, locked: bool) -> Result<(), DbError> {
let value = if locked { "1" } else { "0" };
self.db
.prepare(
"INSERT INTO admin_config (key, value) VALUES ('submissions_locked', ?1) \
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
)
.bind(&[JsValue::from_str(value)])
.map_err(|e| DbError::Internal(e.to_string()))?
.run()
.await
.map(|_| ())
.map_err(|e| DbError::Internal(e.to_string()))
}
/// Seed the `submissions_locked` key as `"0"` if not already present.
///
/// Uses `INSERT OR IGNORE` so calling it multiple times is safe.
async fn seed_submissions_locked(&self) -> Result<(), DbError> {
self.db
.prepare(
"INSERT OR IGNORE INTO admin_config (key, value) \
VALUES ('submissions_locked', '0')",
)
.run()
.await
.map(|_| ())
.map_err(|e| DbError::Internal(e.to_string()))
}
/// Create a moderation report for an existing quote.
///
/// Checks that the quote exists via a COUNT query, then inserts a new row
/// into the `reports` table. Returns `Err(DbError::NotFound)` if no quote
/// with the given `quote_id` exists.
async fn create_report(&self, quote_id: &str, reason: Option<&str>) -> Result<(), DbError> {
// Step 1: verify the quote exists.
let exists_row = self
.db
.prepare("SELECT COUNT(*) AS count FROM quotes WHERE id = ?1")
.bind(&[JsValue::from_str(quote_id)])
.map_err(|e| DbError::Internal(e.to_string()))?
.first::<CountRow>(None)
.await
.map_err(|e| DbError::Internal(e.to_string()))?;
let exists = exists_row.map(|r| r.count > 0).unwrap_or(false);
if !exists {
return Err(DbError::NotFound);
}
// Step 2: insert the report row.
let id = generate_id();
let reason_value = match reason {
Some(r) => JsValue::from_str(r),
None => JsValue::NULL,
};
self.db
.prepare("INSERT INTO reports (id, quote_id, reason) VALUES (?1, ?2, ?3)")
.bind(&[
JsValue::from_str(&id),
JsValue::from_str(quote_id),
reason_value,
])
.map_err(|e| DbError::Internal(e.to_string()))?
.run()
.await
.map(|_| ())
.map_err(|e| DbError::Internal(e.to_string()))
}
/// List all quotes that have at least one report, paginated (10 per page).
///
/// Returns a [`super::ReportListResult`] ordered by most recent report
/// descending. Page numbers are 1-based.
async fn list_reports(&self, page: u32) -> Result<super::ReportListResult, DbError> {
#[derive(serde::Deserialize)]
struct TotalRow {
total: u32,
}
#[derive(serde::Deserialize)]
struct SummaryRow {
quote_id: String,
text: String,
author: String,
report_count: u32,
latest_report_at: String,
}
let page = page.max(1);
let offset = (page - 1) * 10;
// Count distinct quoted with at least one report.
let total_row = self
.db
.prepare("SELECT COUNT(DISTINCT quote_id) AS total FROM reports")
.first::<TotalRow>(None)
.await
.map_err(|e| DbError::Internal(e.to_string()))?;
let total_count = total_row.map(|r| r.total).unwrap_or(0);
let total_pages = total_count.div_ceil(10);
// Aggregate: one row per quote, sorted by most recent report.
let raw_rows = self
.db
.prepare(
"SELECT q.id AS quote_id, q.text, q.author, COUNT(r.id) AS report_count, \
MAX(r.created_at) AS latest_report_at \
FROM reports r \
JOIN quotes q ON q.id = r.quote_id \
GROUP BY r.quote_id \
ORDER BY latest_report_at DESC \
LIMIT 10 OFFSET ?1",
)
.bind(&[JsValue::from_f64(offset as f64)])
.map_err(|e| DbError::Internal(e.to_string()))?
.all()
.await
.map_err(|e| DbError::Internal(e.to_string()))?
.results::<SummaryRow>()
.map_err(|e| DbError::Internal(e.to_string()))?;
let summaries = raw_rows
.into_iter()
.map(|r| {
let truncated = if r.text.chars().count() > 80 {
r.text.chars().take(80).collect()
} else {
r.text
};
super::ReportSummary {
quote_id: r.quote_id,
text: truncated,
author: r.author,
report_count: r.report_count,
latest_report_at: r.latest_report_at,
}
})
.collect();
Ok(super::ReportListResult {
reports: summaries,
page,
total_pages,
total_count,
})
}
/// Return the full quote plus all individual report rows for a quote.
///
/// Reports are ordered oldest first by `created_at`.
/// Returns `Err(DbError::NotFound)` if no quote with `quote_id` exists.
async fn get_reports_for_quote(&self, quote_id: &str) -> Result<super::QuoteReports, DbError> {
#[derive(serde::Deserialize)]
struct ReportRowRaw {
id: String,
reason: Option<String>,
created_at: String,
}
// Fetch the quote.
let maybe_row = self
.db
.prepare(
"SELECT id, text, author, source, date, hidden, created_at, updated_at \
FROM quotes WHERE id = ?1",
)
.bind(&[JsValue::from_str(quote_id)])
.map_err(|e| DbError::Internal(e.to_string()))?
.first::<QuoteRow>(None)
.await
.map_err(|e| DbError::Internal(e.to_string()))?;
let row = maybe_row.ok_or(DbError::NotFound)?;
let tags = self.fetch_tags(&row.id).await?;
let quote = row.into_quote(tags);
// Fetch all reports.
let report_rows = self
.db
.prepare(
"SELECT id, reason, created_at FROM reports \
WHERE quote_id = ?1 ORDER BY created_at ASC",
)
.bind(&[JsValue::from_str(quote_id)])
.map_err(|e| DbError::Internal(e.to_string()))?
.all()
.await
.map_err(|e| DbError::Internal(e.to_string()))?
.results::<ReportRowRaw>()
.map_err(|e| DbError::Internal(e.to_string()))?;
let reports = report_rows
.into_iter()
.map(|r| super::ReportRow {
id: r.id,
reason: r.reason,
created_at: r.created_at,
})
.collect();
Ok(super::QuoteReports { quote, reports })
}
/// Delete a quote unconditionally (admin action).
///
/// Returns `Err(DbError::NotFound)` if the quote does not exist.
/// Tags and reports are removed via `ON DELETE CASCADE`.
async fn admin_delete_quote(&self, quote_id: &str) -> Result<(), DbError> {
// Verify the quote exists first.
let exists_row = self
.db
.prepare("SELECT COUNT(*) AS count FROM quotes WHERE id = ?1")
.bind(&[JsValue::from_str(quote_id)])
.map_err(|e| DbError::Internal(e.to_string()))?
.first::<CountRow>(None)
.await
.map_err(|e| DbError::Internal(e.to_string()))?;
let exists = exists_row.map(|r| r.count > 0).unwrap_or(false);
if !exists {
return Err(DbError::NotFound);
}
self.db
.prepare("DELETE FROM quotes WHERE id = ?1")
.bind(&[JsValue::from_str(quote_id)])
.map_err(|e| DbError::Internal(e.to_string()))?
.run()
.await
.map(|_| ())
.map_err(|e| DbError::Internal(e.to_string()))
}
/// Set `hidden = 1` on a quote (admin action).
///
/// Returns `Err(DbError::NotFound)` if the quote does not exist.
async fn hide_quote(&self, quote_id: &str) -> Result<(), DbError> {
// Verify the quote exists first.
let exists_row = self
.db
.prepare("SELECT COUNT(*) AS count FROM quotes WHERE id = ?1")
.bind(&[JsValue::from_str(quote_id)])
.map_err(|e| DbError::Internal(e.to_string()))?
.first::<CountRow>(None)
.await
.map_err(|e| DbError::Internal(e.to_string()))?;
let exists = exists_row.map(|r| r.count > 0).unwrap_or(false);
if !exists {
return Err(DbError::NotFound);
}
self.db
.prepare("UPDATE quotes SET hidden = 1 WHERE id = ?1")
.bind(&[JsValue::from_str(quote_id)])
.map_err(|e| DbError::Internal(e.to_string()))?
.run()
.await
.map(|_| ())
.map_err(|e| DbError::Internal(e.to_string()))
}
/// Delete all reports for a quote without deleting the quote itself.
///
/// Returns `Err(DbError::NotFound)` if the quote does not exist.
async fn clear_reports(&self, quote_id: &str) -> Result<(), DbError> {
// Verify the quote exists first.
let exists_row = self
.db
.prepare("SELECT COUNT(*) AS count FROM quotes WHERE id = ?1")
.bind(&[JsValue::from_str(quote_id)])
.map_err(|e| DbError::Internal(e.to_string()))?
.first::<CountRow>(None)
.await
.map_err(|e| DbError::Internal(e.to_string()))?;
let exists = exists_row.map(|r| r.count > 0).unwrap_or(false);
if !exists {
return Err(DbError::NotFound);
}
self.db
.prepare("DELETE FROM reports WHERE quote_id = ?1")
.bind(&[JsValue::from_str(quote_id)])
.map_err(|e| DbError::Internal(e.to_string()))?
.run()
.await
.map(|_| ())
.map_err(|e| DbError::Internal(e.to_string()))
}
}

@ -0,0 +1,73 @@
//! SQL migration strings for the `quotesdb` schema.
//!
//! These strings are run once on startup via [`super::QuoteRepository::run_migrations`].
//! Both the `D1Repository` (WASM) and `NativeRepository` (native) execute these
//! in sequence.
/// Creates the `quotes` table if it does not already exist.
///
/// Stores one row per quote with all core fields. The `auth_code` is stored
/// plaintext for simple passphrase-based ownership verification.
pub const CREATE_QUOTES: &str = "\
CREATE TABLE IF NOT EXISTS quotes (
id TEXT PRIMARY KEY,
text TEXT NOT NULL,
author TEXT NOT NULL,
source TEXT,
date TEXT,
auth_code TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)";
/// Creates the `quote_tags` join table if it does not already exist.
///
/// Uses `ON DELETE CASCADE` so tags are removed automatically when a quote
/// is deleted. The composite primary key prevents duplicate tags per quote.
pub const CREATE_QUOTE_TAGS: &str = "\
CREATE TABLE IF NOT EXISTS quote_tags (
quote_id TEXT NOT NULL REFERENCES quotes(id) ON DELETE CASCADE,
tag TEXT NOT NULL,
PRIMARY KEY (quote_id, tag)
)";
/// Creates an index on `quote_tags.quote_id` to speed up tag lookups.
pub const CREATE_TAG_INDEX: &str = "\
CREATE INDEX IF NOT EXISTS idx_quote_tags_quote_id ON quote_tags(quote_id)";
/// Creates a case-insensitive index on `quotes.author` for filter queries.
pub const CREATE_AUTHOR_INDEX: &str = "\
CREATE INDEX IF NOT EXISTS idx_quotes_author ON quotes(author COLLATE NOCASE)";
/// Creates the `admin_config` key/value table for global configuration.
///
/// Stores a single row for the admin auth code under key `admin_auth_code`.
pub const CREATE_ADMIN_CONFIG: &str = "\
CREATE TABLE IF NOT EXISTS admin_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)";
/// Adds the `hidden` column to the `quotes` table.
///
/// This is a schema migration for existing databases. The column defaults to
/// `0` (not hidden) so all pre-existing quotes remain publicly visible.
///
/// SQLite does not support `ADD COLUMN IF NOT EXISTS`, so callers must
/// ignore the error when the column already exists (e.g., on repeated startup).
pub const ALTER_QUOTES_ADD_HIDDEN: &str = "\
ALTER TABLE quotes ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0";
/// Creates the `reports` table if it does not already exist.
///
/// Each row represents one user-submitted report against a quote.
/// `quote_id` references `quotes(id)` with `ON DELETE CASCADE` so reports
/// are removed automatically when the associated quote is deleted.
/// `reason` is optional and capped at 256 characters by application logic.
pub const CREATE_REPORTS: &str = "\
CREATE TABLE IF NOT EXISTS reports (
id TEXT PRIMARY KEY,
quote_id TEXT NOT NULL REFERENCES quotes(id) ON DELETE CASCADE,
reason TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)";

@ -0,0 +1,275 @@
//! Database abstraction layer for the `quotesdb` API.
//!
//! Provides the [`QuoteRepository`] async trait as a uniform interface over
//! two backend implementations:
//!
//! - [`NativeRepository`] — `rusqlite` + `tokio-rusqlite` for native/test targets.
//! - `D1Repository` — Cloudflare D1 via workers-rs for WASM/production targets.
//!
//! The correct implementation is selected at compile time via `cfg(target_arch)`.
//! No feature flags or runtime branching are needed.
pub mod migrations;
#[cfg(not(target_arch = "wasm32"))]
mod native;
#[cfg(all(target_arch = "wasm32", feature = "workers-api"))]
pub mod d1;
#[cfg(not(target_arch = "wasm32"))]
pub mod connection;
#[cfg(not(target_arch = "wasm32"))]
pub use native::NativeRepository;
use serde::{Deserialize, Serialize};
// ── Shared result types ───────────────────────────────────────────────────────
/// A paginated list of quotes.
///
/// Returned by [`QuoteRepository::list_quotes`].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ListResult {
/// The quotes on this page.
pub quotes: Vec<crate::Quote>,
/// Current page number (1-based).
pub page: u32,
/// Total number of pages.
pub total_pages: u32,
/// Total number of quotes matching the filter.
pub total_count: u32,
}
/// A single report row returned by admin report queries.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReportRow {
/// Unique report ID (NanoID).
pub id: String,
/// Optional human-readable reason supplied by the reporter.
pub reason: Option<String>,
/// ISO timestamp when the report was created.
pub created_at: String,
}
/// Summary of a reported quote, returned in the paginated reports list.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReportSummary {
/// The ID of the reported quote.
pub quote_id: String,
/// Abbreviated quote text (first 80 chars).
pub text: String,
/// Author of the reported quote.
pub author: String,
/// Total number of reports against this quote.
pub report_count: u32,
/// ISO timestamp of the most recent report.
pub latest_report_at: String,
}
/// A paginated list of reported quotes.
///
/// Returned by [`QuoteRepository::list_reports`].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReportListResult {
/// Summaries of reported quotes on this page.
pub reports: Vec<ReportSummary>,
/// Current page number (1-based).
pub page: u32,
/// Total number of pages.
pub total_pages: u32,
/// Total number of quotes with at least one report.
pub total_count: u32,
}
/// Full details for a reported quote: the quote itself plus all report rows.
///
/// Returned by [`QuoteRepository::get_reports_for_quote`].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QuoteReports {
/// The full quote object.
pub quote: crate::Quote,
/// All reports submitted against this quote, ordered oldest first.
pub reports: Vec<ReportRow>,
}
/// Outcome of a delete operation.
#[derive(Debug, PartialEq)]
pub enum DeleteResult {
/// The quote was deleted successfully.
Deleted,
/// No quote with the given ID exists.
NotFound,
/// The auth code did not match — deletion refused.
Forbidden,
}
/// Errors returned by [`QuoteRepository`] methods.
#[derive(Debug, thiserror::Error)]
pub enum DbError {
/// An internal database error occurred.
#[error("database error: {0}")]
Internal(String),
/// The requested resource does not exist.
#[error("not found")]
NotFound,
/// The operation is forbidden (wrong auth code).
#[error("forbidden")]
Forbidden,
}
// ── Trait ─────────────────────────────────────────────────────────────────────
/// Async repository interface for all quote CRUD operations.
///
/// On native targets the trait uses `async_trait` (Send-capable futures),
/// which lets Axum share the repository across Tokio tasks.
/// On wasm32 the trait uses `async_trait(?Send)` because D1 database methods
/// internally use `JsFuture` (which is `!Send`). Handler futures are wrapped
/// with `#[worker::send]` at the call site to satisfy Axum's `Handler` bounds.
///
/// Implementations must be backed by a persistent store (SQLite for native,
/// Cloudflare D1 for WASM). The caller holds the repository behind an `Arc`
/// so it can be shared across Axum handler calls.
#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
pub trait QuoteRepository {
/// Run `CREATE TABLE IF NOT EXISTS` migrations.
///
/// Covers the `quotes`, `quote_tags`, index, and `admin_config` tables.
/// Must be called once on startup before any other operations.
async fn run_migrations(&self) -> Result<(), DbError>;
/// List quotes with optional filtering and pagination.
///
/// Page numbers are 1-based. Returns an empty `quotes` vec when `page`
/// is beyond the last page. `date_after` and `date_before` are ISO date
/// prefix strings (e.g. `"2020"`, `"2020-06"`, `"2020-06-15"`); the DB
/// layer uses `>=` / `<=` comparisons against the stored `date` column.
async fn list_quotes(
&self,
page: u32,
author: Option<&str>,
tag: Option<&str>,
date_after: Option<&str>,
date_before: Option<&str>,
) -> Result<ListResult, DbError>;
/// Retrieve a single quote by its ID.
///
/// Returns `Ok(None)` when no quote with the given ID exists.
async fn get_quote(&self, id: &str) -> Result<Option<crate::Quote>, DbError>;
/// Return a single random quote.
///
/// Returns `Ok(None)` when the database is empty.
async fn get_random_quote(&self) -> Result<Option<crate::Quote>, DbError>;
/// Create a new quote.
///
/// If `input.auth_code` is `None`, a 4-word passphrase is auto-generated.
/// Returns the stored quote (without auth_code) and the auth_code string.
async fn create_quote(
&self,
input: crate::CreateQuoteInput,
) -> Result<(crate::Quote, String), DbError>;
/// Update an existing quote.
///
/// The `auth_code` header value must match `quotes.auth_code`.
/// Returns `Err(DbError::NotFound)` if the ID does not exist.
/// Returns `Err(DbError::Forbidden)` if the auth code does not match.
async fn update_quote(
&self,
id: &str,
input: crate::UpdateQuoteInput,
auth_code: &str,
) -> Result<crate::Quote, DbError>;
/// Delete a quote by ID.
///
/// The `auth_code` header value must match `quotes.auth_code` or the
/// admin super auth code stored in `admin_config`.
/// Tags are removed automatically via `ON DELETE CASCADE`.
async fn delete_quote(&self, id: &str, auth_code: &str) -> Result<DeleteResult, DbError>;
/// Retrieve the admin super auth code from `admin_config`.
///
/// Returns `Ok(None)` if the admin code has not been seeded yet.
async fn get_admin_auth_code(&self) -> Result<Option<String>, DbError>;
/// Store the admin auth code in `admin_config` if not already present.
///
/// Uses `INSERT OR IGNORE` so calling it multiple times is safe.
async fn seed_admin_auth_code(&self, code: &str) -> Result<(), DbError>;
/// Replace the admin auth code if `current` matches the stored value.
///
/// If `new_code` is `None`, a fresh 4-word passphrase is auto-generated.
/// Returns the new auth code on success.
/// Returns `Err(DbError::Forbidden)` if `current` does not match.
async fn update_admin_auth_code(
&self,
current: &str,
new_code: Option<&str>,
) -> Result<String, DbError>;
/// Return whether submissions are currently locked.
///
/// Reads the `submissions_locked` key from `admin_config`.
/// Returns `false` if the key has not been seeded yet (defaults to open).
async fn get_submissions_locked(&self) -> Result<bool, DbError>;
/// Persist the submissions lock state.
///
/// Writes `"1"` (locked) or `"0"` (unlocked) to the `submissions_locked`
/// key in `admin_config`, upserting if necessary.
async fn set_submissions_locked(&self, locked: bool) -> Result<(), DbError>;
/// Seed the `submissions_locked` key as `"0"` if not already present.
///
/// Uses `INSERT OR IGNORE` so calling it multiple times is safe.
/// Call once on startup to ensure the key exists.
async fn seed_submissions_locked(&self) -> Result<(), DbError>;
/// Create a moderation report for an existing quote.
///
/// `reason` is optional and should be at most 256 chars (enforced at the
/// handler layer before this method is called).
///
/// Returns `Err(DbError::NotFound)` if `quote_id` does not exist in the
/// `quotes` table.
async fn create_report(&self, quote_id: &str, reason: Option<&str>) -> Result<(), DbError>;
/// List all quotes that have at least one report, paginated (10 per page).
///
/// Each entry includes: quote ID, truncated text, author, report count,
/// and the timestamp of the most recent report. Page numbers are 1-based.
async fn list_reports(&self, page: u32) -> Result<ReportListResult, DbError>;
/// Return the full quote plus all individual report rows for a quote.
///
/// Reports are ordered oldest first by `created_at`.
/// Returns `Err(DbError::NotFound)` if the quote does not exist.
async fn get_reports_for_quote(&self, quote_id: &str) -> Result<QuoteReports, DbError>;
/// Delete a quote unconditionally (admin action).
///
/// Does not verify per-quote auth code — caller must authenticate as admin
/// before invoking this method. Tags and reports are removed automatically
/// by the `ON DELETE CASCADE` constraints.
/// Returns `Err(DbError::NotFound)` if the quote does not exist.
async fn admin_delete_quote(&self, quote_id: &str) -> Result<(), DbError>;
/// Set `hidden = 1` on a quote (admin action).
///
/// Does not verify per-quote auth code — caller must authenticate as admin.
/// Returns `Err(DbError::NotFound)` if the quote does not exist.
async fn hide_quote(&self, quote_id: &str) -> Result<(), DbError>;
/// Delete all reports for a quote without deleting the quote itself.
///
/// Returns `Err(DbError::NotFound)` if the quote does not exist.
async fn clear_reports(&self, quote_id: &str) -> Result<(), DbError>;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

@ -0,0 +1,23 @@
# Wrangler configuration for the quotesdb API Worker.
# Used by `wrangler deploy` (invoked via `just deploy-api` or tofu apply).
# wrangler is required because the Cloudflare Terraform provider v4 does not
# support uploading ES module workers that import wasm files — wrangler handles
# the multipart module bundle upload correctly.
name = "quotesdb-api"
main = "build/index.js"
compatibility_date = "2024-11-01"
# Route: maps quotes.elijah.run/api/* to this worker.
# Managed here rather than in Terraform because wrangler already owns the script
# upload (provider v4 can't handle ES module + wasm), so it's simpler to let
# wrangler own the route too.
[[routes]]
pattern = "quotes.elijah.run/api/*"
zone_name = "elijah.run"
# D1 database binding — referenced in workers-rs code as `env.DB`.
[[d1_databases]]
binding = "DB"
database_name = "quotesdb"
database_id = "42aadd79-ce31-4ee0-b9d7-04d06062c607"
Loading…
Cancel
Save