diff --git a/flake.lock b/flake.lock
index 0197e99..fcec752 100644
--- a/flake.lock
+++ b/flake.lock
@@ -51,11 +51,11 @@
},
"nixpkgs_2": {
"locked": {
- "lastModified": 1772419343,
- "narHash": "sha256-QU3Cd5DJH7dHyMnGEFfPcZDaCAsJQ6tUD+JuUsYqnKU=",
+ "lastModified": 1772956932,
+ "narHash": "sha256-M0yS4AafhKxPPmOHGqIV0iKxgNO8bHDWdl1kOwGBwRY=",
"owner": "NixOS",
"repo": "nixpkgs",
- "rev": "93178f6a00c22fcdee1c6f5f9ab92f2072072ea9",
+ "rev": "608d0cadfed240589a7eea422407a547ad626a14",
"type": "github"
},
"original": {
@@ -101,11 +101,11 @@
]
},
"locked": {
- "lastModified": 1772420823,
- "narHash": "sha256-q3oVwz1Rx41D1D+F6vg41kpOkk3Zi3KwnkHEZp7DCGs=",
+ "lastModified": 1773025773,
+ "narHash": "sha256-Wik8+xApNfldpUFjPmJkPdg0RrvUPSWGIZis+A/0N1w=",
"owner": "oxalica",
"repo": "rust-overlay",
- "rev": "458eea8d905c609e9d889423e6b8a1c7bc2f792c",
+ "rev": "3c06fdbbd36ff60386a1e590ee0cd52dcd1892bf",
"type": "github"
},
"original": {
diff --git a/flake.nix b/flake.nix
index 56f6b3b..505414e 100644
--- a/flake.nix
+++ b/flake.nix
@@ -43,6 +43,7 @@
# Cloudflare
pkgs.wrangler
+ pkgs.worker-build
# General
pkgs.pkg-config
@@ -62,6 +63,8 @@
# Build book
pkgs.mdbook
+
+ pkgs.just
];
shellHook = ''
diff --git a/quotesdb/.gitignore b/quotesdb/.gitignore
index 43217b4..29b01f0 100644
--- a/quotesdb/.gitignore
+++ b/quotesdb/.gitignore
@@ -1,2 +1,7 @@
dist/
+
+# Binary database file
quotesdb.sqlite*
+
+# compiled files
+*.wasm
diff --git a/quotesdb/Cargo.lock b/quotesdb/Cargo.lock
index 2f7bf65..8782dd0 100644
--- a/quotesdb/Cargo.lock
+++ b/quotesdb/Cargo.lock
@@ -455,7 +455,7 @@ dependencies = [
"gloo-events",
"gloo-utils",
"serde",
- "serde-wasm-bindgen 0.6.5",
+ "serde-wasm-bindgen",
"serde_urlencoded",
"thiserror 1.0.69",
"wasm-bindgen",
@@ -1465,17 +1465,6 @@ dependencies = [
"serde_derive",
]
-[[package]]
-name = "serde-wasm-bindgen"
-version = "0.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f3b143e2833c57ab9ad3ea280d21fd34e285a42837aeb0ee301f4f41890fa00e"
-dependencies = [
- "js-sys",
- "serde",
- "wasm-bindgen",
-]
-
[[package]]
name = "serde-wasm-bindgen"
version = "0.6.5"
@@ -2091,9 +2080,9 @@ dependencies = [
[[package]]
name = "wasm-streams"
-version = "0.4.2"
+version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
+checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb"
dependencies = [
"futures-util",
"js-sys",
@@ -2404,9 +2393,9 @@ dependencies = [
[[package]]
name = "worker"
-version = "0.5.0"
+version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "727789ca7eff9733efbea9d0e97779edc1cf1926e98aee7d7d8afe32805458aa"
+checksum = "7267f3baa986254a8dace6f6a7c6ab88aef59f00c03aaad6749e048b5faaf6f6"
dependencies = [
"async-trait",
"bytes",
@@ -2419,7 +2408,7 @@ dependencies = [
"matchit 0.7.3",
"pin-project",
"serde",
- "serde-wasm-bindgen 0.6.5",
+ "serde-wasm-bindgen",
"serde_json",
"serde_urlencoded",
"tokio",
@@ -2428,31 +2417,15 @@ dependencies = [
"wasm-bindgen-futures",
"wasm-streams",
"web-sys",
- "worker-kv",
"worker-macros",
"worker-sys",
]
-[[package]]
-name = "worker-kv"
-version = "0.7.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7f06d4d1416a9f8346ee9123b0d9a11b3cfa38e6cfb5a139698017d1597c4d41"
-dependencies = [
- "js-sys",
- "serde",
- "serde-wasm-bindgen 0.5.0",
- "serde_json",
- "thiserror 1.0.69",
- "wasm-bindgen",
- "wasm-bindgen-futures",
-]
-
[[package]]
name = "worker-macros"
-version = "0.5.0"
+version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7d625c24570ba9207a2617476013335f28a95cbe513e59bb814ffba092a18058"
+checksum = "7410081121531ec2fa111ab17b911efc601d7b6d590c0a92b847874ebeff0030"
dependencies = [
"async-trait",
"proc-macro2",
@@ -2466,9 +2439,9 @@ dependencies = [
[[package]]
name = "worker-sys"
-version = "0.5.0"
+version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "34563340d41016b4381257c5a16b0d2bc590dbe00500ecfbebcaa16f5f85ce90"
+checksum = "4777582bf8a04174a034cb336f3702eb0e5cb444a67fdaa4fd44454ff7e2dd95"
dependencies = [
"cfg-if",
"js-sys",
diff --git a/quotesdb/Cargo.toml b/quotesdb/Cargo.toml
index f4bc41a..ab425fe 100644
--- a/quotesdb/Cargo.toml
+++ b/quotesdb/Cargo.toml
@@ -5,6 +5,15 @@ edition = "2021"
license = "MIT OR Apache-2.0"
default-run = "api"
+[features]
+# Enable API server code: Cloudflare D1 bindings, route handlers, and the
+# Workers fetch event entry point. Required for `cargo run` (native) and
+# `worker-build` (wasm32 Workers). Do NOT enable for Trunk UI builds.
+workers-api = []
+
+[lib]
+crate-type = ["cdylib", "rlib"]
+
[[bin]]
name = "api"
path = "src/bin/api/main.rs"
@@ -52,7 +61,7 @@ uuid = { version = "1", features = ["js"] }
# Cloudflare Workers SDK — provides D1 bindings, fetch, KV, etc.
# The `http` feature makes the fetch handler accept/return standard http types,
# enabling direct Axum router integration without manual request conversion.
-worker = { version = "0.5", features = ["d1", "http"] }
+worker = { version = "0.7", features = ["d1", "http"] }
# Axum HTTP framework for the WASM Workers entry point — minimal feature set
# (no full tokio runtime needed; json and query features required for handlers).
axum = { version = "0.8", default-features = false, features = ["json", "query"] }
diff --git a/quotesdb/index.html b/quotesdb/index.html
index f761992..dd626a8 100644
--- a/quotesdb/index.html
+++ b/quotesdb/index.html
@@ -5,7 +5,7 @@
QuotesDB
-
+
diff --git a/quotesdb/infra/README.md b/quotesdb/infra/README.md
index daa2780..43f0e57 100644
--- a/quotesdb/infra/README.md
+++ b/quotesdb/infra/README.md
@@ -8,7 +8,7 @@ OpenTofu configuration for deploying quotesdb to Cloudflare.
|---|---|
| `cloudflare_d1_database.quotesdb` | D1 SQLite database backing the API |
| `cloudflare_workers_script.api` | Compiled Wasm Worker serving `/api/*` |
-| `cloudflare_worker_route.api` | Routes `quotes.elijah.run/api/*` to the Worker |
+| `cloudflare_workers_route.api` | Routes `quotes.elijah.run/api/*` to the Worker |
| `cloudflare_pages_project.ui` | Pages project hosting the Yew SPA |
| `cloudflare_record.ui` | CNAME `quotes.elijah.run` → Pages |
| `cloudflare_pages_domain.ui` | Custom domain binding on Pages |
diff --git a/quotesdb/infra/main.tf b/quotesdb/infra/main.tf
index 26f67e1..e85e64b 100644
--- a/quotesdb/infra/main.tf
+++ b/quotesdb/infra/main.tf
@@ -7,5 +7,10 @@ terraform {
source = "registry.terraform.io/cloudflare/cloudflare"
version = "~> 4"
}
+ # null provider for wrangler-based worker deployment via local-exec.
+ null = {
+ source = "registry.terraform.io/hashicorp/null"
+ version = "~> 3"
+ }
}
}
diff --git a/quotesdb/infra/rate-limits.tf b/quotesdb/infra/rate-limits.tf
index 196b6d5..21e02af 100644
--- a/quotesdb/infra/rate-limits.tf
+++ b/quotesdb/infra/rate-limits.tf
@@ -1,8 +1,9 @@
# Cloudflare WAF rate limiting rules for the quotesdb API.
# Uses the http_ratelimit phase of cloudflare_ruleset to enforce per-IP request caps
-# on all mutating endpoints. Rules are evaluated top-to-bottom; first match wins.
-# The report rule must appear before the general update rule to prevent the broader
-# /api/quotes/* pattern from matching /api/quotes/*/report first.
+# on all mutating endpoints.
+#
+# NOTE: The Cloudflare Free plan allows only 1 rule per zone in the http_ratelimit
+# phase, so all mutating endpoints are combined into a single rule.
resource "cloudflare_ruleset" "api_rate_limits" {
# Scoped to the elijah.run zone that hosts quotes.elijah.run.
zone_id = var.cloudflare_zone_id
@@ -12,58 +13,22 @@ resource "cloudflare_ruleset" "api_rate_limits" {
phase = "http_ratelimit"
rules {
- # Limit quote creation via PUT /api/quotes: 5 requests per IP per 10 minutes.
- # PUT is the create verb per the quotesdb API design.
- description = "Limit PUT /api/quotes to 5 per IP per 10 minutes"
- expression = "(http.request.method eq \"PUT\" and http.request.uri.path eq \"/api/quotes\")"
+ # Limit all requests to /api/ to 2 per IP per 10-second window.
+ # Free plan restrictions:
+ # - expression fields: Path and Verified Bot only (no Method, no regex)
+ # - characteristics: IP only (no cf.colo.id)
+ # - period: must be 10 (only allowed value on Free)
+ # - operator: no `matches` (regex); use `contains` instead
+ description = "Limit /api/ requests to 2 per IP per 10 seconds"
+ expression = "http.request.uri.path contains \"/api/quotes\""
action = "block"
ratelimit {
- characteristics = ["ip.src"]
- period = 600
- requests_per_period = 5
- mitigation_timeout = 600
- }
- }
-
- rules {
- # Limit report submissions: 3 POST /api/quotes/:id/report per IP per hour.
- # This rule must precede the general update rule below so the more-specific
- # /report path is matched first before the broader /api/quotes/* pattern.
- description = "Limit POST /api/quotes/*/report to 3 per IP per hour"
- expression = "(http.request.method eq \"POST\" and http.request.uri.path matches \"/api/quotes/[^/]+/report$\")"
- action = "block"
- ratelimit {
- characteristics = ["ip.src"]
- period = 3600
- requests_per_period = 3
- mitigation_timeout = 3600
- }
- }
-
- rules {
- # Limit quote updates: 10 POST /api/quotes/:id per IP per minute.
- # Excludes the /report sub-path (handled by the rule above).
- description = "Limit POST /api/quotes/:id to 10 per IP per minute"
- expression = "(http.request.method eq \"POST\" and http.request.uri.path matches \"/api/quotes/[^/]+$\")"
- action = "block"
- ratelimit {
- characteristics = ["ip.src"]
- period = 60
- requests_per_period = 10
- mitigation_timeout = 60
- }
- }
-
- rules {
- # Limit quote deletes: 10 DELETE /api/quotes/:id per IP per minute.
- description = "Limit DELETE /api/quotes/:id to 10 per IP per minute"
- expression = "(http.request.method eq \"DELETE\" and http.request.uri.path matches \"/api/quotes/[^/]+$\")"
- action = "block"
- ratelimit {
- characteristics = ["ip.src"]
- period = 60
- requests_per_period = 10
- mitigation_timeout = 60
+ # cf.colo.id is required by the API (20155) — rate limiting is processed
+ # per-colocation and must always be included alongside ip.src.
+ characteristics = ["ip.src", "cf.colo.id"]
+ period = 10
+ requests_per_period = 2
+ mitigation_timeout = 10
}
}
}
diff --git a/quotesdb/infra/worker.tf b/quotesdb/infra/worker.tf
index ca15f38..7914ab3 100644
--- a/quotesdb/infra/worker.tf
+++ b/quotesdb/infra/worker.tf
@@ -1,27 +1,30 @@
-# Cloudflare Workers script for the quotesdb API.
-# Compiled from the `api` binary targeting wasm32-unknown-unknown.
-# Build before applying: cargo build --release --bin api --target wasm32-unknown-unknown
-resource "cloudflare_workers_script" "api" {
- account_id = var.cloudflare_account_id
-
- # Script name used in the Cloudflare dashboard and for routing.
- name = "quotesdb-api"
-
- # Compiled Wasm binary — path is relative to the infra/ directory.
- content = filebase64("../target/wasm32-unknown-unknown/release/api.wasm")
-
- # D1 database binding — referenced in workers-rs code as `env.DB`.
- # Depends implicitly on cloudflare_d1_database.quotesdb via attribute reference.
- d1_database_binding {
- name = "DB"
- database_id = cloudflare_d1_database.quotesdb.id
+# Deploys the quotesdb API Worker using wrangler.
+# The Cloudflare Terraform provider v4 cannot upload ES module workers that
+# import wasm files (error 10021). wrangler handles the multipart module bundle
+# upload correctly, so we use a null_resource with local-exec instead.
+#
+# The worker route (quotes.elijah.run/api/*) is also managed by wrangler via
+# wrangler.toml [[routes]] — keeping route and script lifecycle together avoids
+# authentication errors from split ownership.
+#
+# Build before applying: just build-api (runs worker-build --release)
+# Output lands in build/: index.js (ES module entry) + index_bg.wasm (wasm-bindgen output).
+resource "null_resource" "worker_deploy" {
+ # Re-deploy whenever the compiled JS or wasm changes.
+ triggers = {
+ js_hash = filemd5("../build/index.js")
+ wasm_hash = filemd5("../build/index_bg.wasm")
}
-}
-# Route that maps quotes.elijah.run/api/* to the quotesdb-api Worker.
-# All other requests on the domain are served by Cloudflare Pages.
-resource "cloudflare_worker_route" "api" {
- zone_id = var.cloudflare_zone_id
- pattern = "quotes.elijah.run/api/*"
- script_name = cloudflare_workers_script.api.name
+ # Run wrangler deploy from the quotesdb project root (where build/ and wrangler.toml live).
+ provisioner "local-exec" {
+ command = "wrangler deploy --config wrangler.toml"
+ working_dir = ".."
+ environment = {
+ CLOUDFLARE_API_TOKEN = var.cloudflare_api_token
+ # Providing the account ID avoids a /memberships API call that fails
+ # with restricted-scope tokens.
+ CLOUDFLARE_ACCOUNT_ID = var.cloudflare_account_id
+ }
+ }
}
diff --git a/quotesdb/src/bin/api/db/connection.rs b/quotesdb/src/bin/api/db/connection.rs
deleted file mode 100644
index 14f480a..0000000
--- a/quotesdb/src/bin/api/db/connection.rs
+++ /dev/null
@@ -1,46 +0,0 @@
-//! 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> {
-/// let repo = quotesdb::db::connection::open("quotesdb.sqlite").await?;
-/// repo.run_migrations().await?;
-/// # Ok(())
-/// # }
-/// ```
-pub async fn open(path: &str) -> Result {
- 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))
-}
diff --git a/quotesdb/src/bin/api/db/d1.rs b/quotesdb/src/bin/api/db/d1.rs
deleted file mode 100644
index d729735..0000000
--- a/quotesdb/src/bin/api/db/d1.rs
+++ /dev/null
@@ -1,942 +0,0 @@
-//! 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 quotesdb::{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,
- date: Option,
- /// 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) -> 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.
-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` of tag values, or an empty vec if none exist.
- async fn fetch_tags(&self, id: &str) -> Result, 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::()
- .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.
- 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
- .exec(sql)
- .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.exec(ALTER_QUOTES_ADD_HIDDEN).await;
- 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 {
- 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 = vec!["q.hidden = 0".to_owned()];
- let mut binds: Vec = 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::(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::()
- .map_err(|e| DbError::Internal(e.to_string()))?;
-
- // Second pass: fetch tags for each quote
- let mut quotes: Vec = 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