Separate command and query

This commit is contained in:
Jon Staab
2026-04-01 15:33:03 -07:00
parent baae65b8b2
commit 07dfe86210
18 changed files with 615 additions and 549 deletions
+13 -13
View File
@@ -5,10 +5,10 @@ Api manages the HTTP interface for the application
Members:
- `host: String` - the hostname of the service for checking NIP 98 auth, from `HOST`
- `port: u16` - a port to run the server on from `PORT`
- `admins: Vec<String>` - a list of admin pubkeys from `ADMINS`
- `origins: Vec<String>` - to be used in CORS headers, from `ALLOW_ORIGINS`
- `repo: Repo`
- `query: Query`
- `command: Command`
- `billing: Billing`
Notes:
@@ -31,7 +31,7 @@ Notes:
- Serves `GET /plans`
- No authentication required
- Return `data` is a list of plan structs from `Repo::list_plans`
- Return `data` is a list of plan structs from `Query::list_plans`
## `async fn get_plan(...) -> Response`
@@ -55,26 +55,26 @@ Notes:
- Serves `GET /tenants`
- Authorizes admin only
- Return `data` is a list of tenant structs from `repo.list_tenants`
- Return `data` is a list of tenant structs from `query.list_tenants`
## `async fn get_tenant(...) -> Response`
- Serves `GET /tenants/:pubkey`
- Authorizes admin or matching tenant
- Return `data` is a single tenant struct from `repo.get_tenant`
- Return `data` is a single tenant struct from `query.get_tenant`
## `async fn update_tenant(...) -> Response`
- Serves `PUT /tenants/:pubkey`
- Authorizes admin or matching tenant
- Updates tenant using `repo.update_tenant`
- Updates tenant using `command.update_tenant`
- Return `data` is the updated tenant struct
## `async fn list_tenant_relays(...) -> Response`
- Serves `GET /tenants/:pubkey/relays`
- Authorizes admin or matching tenant
- Return `data` is a list of relay structs from `repo.list_relays_for_tenant`
- Return `data` is a list of relay structs from `query.list_relays_for_tenant`
--- Relay routes
@@ -82,20 +82,20 @@ Notes:
- Serves `GET /relays`
- Authorizes admin only
- Return `data` is a list of relay structs from `repo.list_relays`
- Return `data` is a list of relay structs from `query.list_relays`
## `async fn get_relay(...) -> Response`
- Serves `GET /relays/:id`
- Authorizes admin or relay owner
- Return `data` is a single relay struct from `repo.get_relay`
- Return `data` is a single relay struct from `query.get_relay`
## `async fn create_relay(...) -> Response`
- Serves `POST /relays`
- Authorizes admin or matching tenant pubkey in request body
- Validates/prepares the relay data to be saved using `prepare_relay`
- Creates a new relay using `repo.create_relay`
- Creates a new relay using `command.create_relay`
- If relay is a duplicate by subdomain, return a `422` with `code=subdomain-exists`
- Return `data` is a single relay struct. Use HTTP `201`.
@@ -104,7 +104,7 @@ Notes:
- Serves `PUT /relays/:id`
- Authorizes admin or relay owner
- Validates/prepares the relay data to be saved using `prepare_relay`
- Updates the given relay using `repo.update_relay`
- Updates the given relay using `command.update_relay`
- If relay is a duplicate by subdomain, return a `422` with `code=subdomain-exists`
- Return `data` is a single relay struct.
@@ -112,7 +112,7 @@ Notes:
- Serves `GET /relays/:id/activity`
- Authorizes admin or relay owner
- Get activity from `repo.list_activity_for_relay`
- Get activity from `query.list_activity_for_relay`
- Return `data` is `{activity}`
## `async fn deactivate_relay(...) -> Response`
+3 -2
View File
@@ -5,9 +5,10 @@ Billing encapsulates logic related to synchronizing state with Stripe.
Members:
- `nwc_url: String` - a nostr wallet connect URL used to **create** bolt11 invoices
- `repo: Repo`
- `query: Query`
- `command: Command`
- `robot: Robot`
## `pub fn new(repo: Repo, robot: Robot) -> Self`
## `pub fn new(query: Query, command: Command, robot: Robot) -> Self`
- Reads environment and populates members
+57
View File
@@ -0,0 +1,57 @@
# `pub struct Command`
Command writes to the database.
Members:
- `pool: SqlitePool` - a sqlite connection pool
Notes:
- All public write methods should be atomic
- All writes should be accompanied by an activity log entry of `(tenant, activity_type, resource_type, resource_id)`
## `pub fn new(&self, pool: SqlitePool) -> Self`
- Assigns pool to self
## `pub fn create_tenant(&self, tenant: &Tenant) -> Result<()>`
- Creates tenant, may throw sqlite uniqueness error on pubkey
- Logs activity as `(create_tenant, tenant_id)`
## `pub fn update_tenant(&self, tenant: &Tenant) -> Result<()>`
- Updates tenant
- Logs activity as `(update_tenant, tenant_id)`
## `pub fn create_relay(&self, relay: &Relay) -> Result<()>`
- Creates relay, may throw sqlite uniqueness error on subdomain
- Sets relay status to `new`
- Logs activity as `(create_relay, relay_id)`
## `pub fn update_relay(&self, relay: &Relay) -> Result<()>`
- Updates relay, may throw sqlite uniqueness error on subdomain
- Logs activity as `(update_relay, relay_id)`
## `pub fn deactivate_relay(&self, relay: &Relay) -> Result<()>`
- Sets relay status to `inactive`
- Logs activity as `(deactivate_relay, relay_id)`
## `pub fn activate_relay(&self, relay: &Relay) -> Result<()>`
- Sets relay status to `active`
- Logs activity as `(activate_relay, relay_id)`
## `pub fn fail_relay_sync(&self, relay: &Relay, sync_error: &str) -> Result<()>`
- Sets relay status to `inactive`, sets `sync_error`
- Logs activity as `(fail_relay_sync, relay_id)`
## `pub fn mark_relay_synced(&self, relay_id: &str) -> Result<()>`
- Sets `synced = 1`, `status = 'active'`, clears `sync_error`
- No activity log (called by infra after successful sync)
+7 -6
View File
@@ -5,25 +5,26 @@ Infra is a service which polls the database and synchronizes updates to relays t
Members:
- `api_url: String` - the URL of the zooid instance to be managed, from `ZOOID_API_URL`
- `repo: Repo`
- `query: Query`
- `command: Command`
## `pub fn new(repo: Repo) -> Self`
## `pub fn new(query: Query, command: Command) -> Self`
- Reads environment and populates members
## `pub async fn start(self)`
- Initializes `last_activity_at` from `repo.max_activity_at()` so historical activities are not replayed on restart.
- Initializes `last_activity_at` from `query.max_activity_at()` so historical activities are not replayed on restart.
- Calls `self.tick` in a loop every 10 seconds.
## `pub async fn tick(self)`
Iterates over `repo.list_activity` since last run and does the following:
Iterates over `query.list_activity` since last run and does the following:
- For `create_relay`, `update_relay`, or `deactivate_relay` activity, sync the relay to zooid.
- Uses `relay.synced` to decide POST vs PUT (not the activity type), so already-synced relays always use PUT even on restart.
- On success, calls `repo.mark_relay_synced` to set `synced = 1`, `status = 'active'`, and clear `sync_error`.
- On failure, calls `repo.fail_relay_sync`.
- On success, calls `command.mark_relay_synced` to set `synced = 1`, `status = 'active'`, and clear `sync_error`.
- On failure, calls `command.fail_relay_sync`.
- All other activity types are ignored (e.g. `fail_relay_sync` must not trigger another sync).
## `async fn sync_relay(&self, relay: &Relay, is_new: bool)`
+2 -2
View File
@@ -1,8 +1,8 @@
# `async fn main() -> Result<()>`
- Configures logging
- Creates instances of `Repo`, `Robot`, `Billing`, `Api`, and `Infra`
- Spawns `infra.start`
- Calls `create_pool` to get a `SqlitePool`, then creates `Query`, `Command`, `Robot`, `Billing`, `Api`, and `Infra`
- Get an axum router from `api.router`
- Adds CORS middleware based on `origins`
- Calls `axum::serve` with a listener
- Spawns `infra.start`
+14
View File
@@ -0,0 +1,14 @@
# `pub async fn create_pool() -> Result<SqlitePool>`
Creates and returns a sqlite connection pool.
Notes:
- Database table names are singular: `activity`, `tenant`, `relay`
Steps:
- Reads `DATABASE_URL` from environment
- Ensures that any directories referred to in `DATABASE_URL` exist
- Initializes the sqlx pool
- Runs migrations found in the `migrations` directory
+50
View File
@@ -0,0 +1,50 @@
# `pub struct Query`
Query reads from the database.
Members:
- `pool: SqlitePool` - a sqlite connection pool
## `pub fn new(&self, pool: SqlitePool) -> Self`
- Assigns pool to self
## `pub fn list_tenants(&self) -> Result<Vec<Tenant>>`
- Returns all tenants
## `pub fn get_tenant(&self, pubkey: &str) -> Result<Tenant>`
- Returns matching tenant
## `pub fn list_plans() -> Vec<Plan>`
- Returns the hardcoded relay plans used by the system (`free`, `basic`, `growth`)
- This is the source of truth for plan metadata exposed via API
## `pub fn list_relays(&self) -> Result<Vec<Relay>>`
- Returns all relays
## `pub fn list_relays_for_tenant(&self, tenant_id: &str) -> Result<Vec<Relay>>`
- Returns all relays belonging to the given tenant
## `pub fn get_relay(&self, id: &str) -> Result<Relay>`
- Returns matching relay
## `pub fn max_activity_at(&self) -> Result<i64>`
- Returns the maximum `created_at` value from the activity table, or 0 if empty
- Used by infra to initialize the since guard on startup
## `pub fn list_activity(&self, since: &i64) -> Result<Vec<Activity>>`
- Returns all activity occuring after `since`
## `pub fn list_activity_for_relay(&self, relay_id: &str) -> Result<Vec<Activity>>`
- Returns all activity where `resource_type = 'relay'` and `resource_id = relay_id`
- Ordered newest-first
-107
View File
@@ -1,107 +0,0 @@
# `pub struct Repo`
Repo is a wrapper around a sqlite pool which implements methods related to database access.
Members:
- `pool: sqlx::SqlitePool` - a sqlite connection pool
Notes:
- All public write methods should be run in a transaction so they're atomic
- All writes should be accompanied by an activity log entry of `(tenant, activity_type, resource_type, resource_id)`
- Database table names are singular: `activity`, `tenant`, `relay`
## `pub fn new() -> Self`
- Reads `DATABASE_URL` from environment
- Ensures that any directories referred to in `DATABASE_URL` exist
- Initializes its sqlx `pool`
- Runs migrations found in the `migrations` directory.
## `fn insert_activity(activity_type, resource_type, resource_id) -> Result<()>`
- Private helper that inserts one row into `activity`
- Infers `tenant` from `resource_type` and `resource_id`
- Used by write methods to avoid repeating audit-log SQL
## `pub fn list_tenants(&self) -> Result<Vec<Tenant>>`
- Returns all tenants
## `pub fn get_tenant(&self, pubkey: &str) -> Result<Tenant>`
- Returns matching tenant
## `pub fn create_tenant(&self, tenant: &Tenant) -> Result<()>`
- Creates tenant, may throw sqlite uniqueness error on pubkey
- Logs activity as `(create_tenant, tenant_id)`
## `pub fn update_tenant(&self, tenant: &Tenant) -> Result<()>`
- Updates tenant
- Logs activity as `(update_tenant, tenant_id)`
## `pub fn list_plans() -> Vec<Plan>`
- Returns the hardcoded relay plans used by the system (`free`, `basic`, `growth`)
- This is the source of truth for plan metadata exposed via API
## `pub fn list_relays(&self) -> Result<Vec<Relay>>`
- Returns all relays
## `pub fn list_relays_for_tenant(&self, tenant_id: &str) -> Result<Vec<Relay>>`
- Returns all relays belonging to the given tenant
## `pub fn get_relay(&self, id: &str) -> Result<Relay>`
- Returns matching relay
## `pub fn create_relay(&self, relay: &Relay) -> Result<()>`
- Creates relay, may throw sqlite uniqueness error on subdomain
- Sets relay status to `new`
- Logs activity as `(create_relay, relay_id)`
## `pub fn update_relay(&self, relay: &Relay) -> Result<()>`
- Updates relay, may throw sqlite uniqueness error on subdomain
- Logs activity as `(update_relay, relay_id)`
## `pub fn deactivate_relay(&self, relay: &Relay) -> Result<()>`
- Sets relay status to `inactive`
- Logs activity as `(deactivate_relay, relay_id)`
## `pub fn activate_relay(&self, relay: &Relay) -> Result<()>`
- Sets relay status to `active`
- Logs activity as `(activate_relay, relay_id)`
## `pub fn fail_relay_sync(&self, relay: &Relay, sync_error: &str) -> Result<()>`
- Sets relay status to `inactive`, sets `sync_error`
- Logs activity as `(fail_relay_sync, relay_id)`
## `pub fn mark_relay_synced(&self, relay_id: &str) -> Result<()>`
- Sets `synced = 1`, `status = 'active'`, clears `sync_error`
- No activity log (called by infra after successful sync)
## `pub fn max_activity_at(&self) -> Result<i64>`
- Returns the maximum `created_at` value from the activity table, or 0 if empty
- Used by infra to initialize the since guard on startup
## `pub fn list_activity(&self, since: &i64) -> Result<Vec<Activity>>`
- Returns all activity occuring after `since`
## `pub fn list_activity_for_relay(&self, relay_id: &str) -> Result<Vec<Activity>>`
- Returns all activity where `resource_type = 'relay'` and `resource_id = relay_id`
- Ordered newest-first
+24 -22
View File
@@ -13,14 +13,16 @@ use nostr_sdk::{Event, JsonUtil, Kind};
use serde::{Deserialize, Serialize};
use crate::billing::Billing;
use crate::command::Command;
use crate::models::{Relay, Tenant};
use crate::repo::Repo;
use crate::query::Query;
#[derive(Clone)]
pub struct Api {
host: String,
admins: Vec<String>,
repo: Repo,
query: Query,
command: Command,
billing: Billing,
}
@@ -57,7 +59,7 @@ impl IntoResponse for ApiError {
}
impl Api {
pub fn new(repo: Repo, billing: Billing) -> Self {
pub fn new(query: Query, command: Command, billing: Billing) -> Self {
let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
let admins = std::env::var("ADMINS")
.unwrap_or_default()
@@ -68,7 +70,8 @@ impl Api {
Self {
host,
admins,
repo,
query,
command,
billing,
}
}
@@ -302,7 +305,7 @@ async fn list_tenants(
let pubkey = state.api.extract_auth_pubkey(&headers)?;
state.api.require_admin(&pubkey)?;
match state.api.repo.list_tenants().await {
match state.api.query.list_tenants().await {
Ok(tenants) => Ok(ok(StatusCode::OK, tenants)),
Err(e) => Ok(err(
StatusCode::INTERNAL_SERVER_ERROR,
@@ -313,7 +316,7 @@ async fn list_tenants(
}
async fn list_plans() -> Response {
ok(StatusCode::OK, Repo::list_plans())
ok(StatusCode::OK, Query::list_plans())
}
async fn get_identity(
@@ -328,7 +331,7 @@ async fn get_identity(
created_at: now_ts(),
};
match state.api.repo.create_tenant(&tenant).await {
match state.api.command.create_tenant(&tenant).await {
Ok(()) => true,
Err(e) if matches!(map_unique_error(&e), Some("pubkey-exists")) => true,
Err(e) => {
@@ -350,7 +353,7 @@ async fn get_identity(
}
async fn get_plan(Path(id): Path<String>) -> Response {
match Repo::list_plans().into_iter().find(|p| p.id == id) {
match Query::list_plans().into_iter().find(|p| p.id == id) {
Some(plan) => ok(StatusCode::OK, plan),
None => err(StatusCode::NOT_FOUND, "not-found", "plan not found"),
}
@@ -364,7 +367,7 @@ async fn get_tenant(
let auth = state.api.extract_auth_pubkey(&headers)?;
state.api.require_admin_or_tenant(&auth, &pubkey)?;
match state.api.repo.get_tenant(&pubkey).await {
match state.api.query.get_tenant(&pubkey).await {
Ok(Some(tenant)) => Ok(ok(StatusCode::OK, tenant)),
Ok(None) => Ok(err(StatusCode::NOT_FOUND, "not-found", "tenant not found")),
Err(e) => Ok(err(
@@ -382,7 +385,7 @@ async fn list_relays(
let pubkey = state.api.extract_auth_pubkey(&headers)?;
state.api.require_admin(&pubkey)?;
match state.api.repo.list_relays().await {
match state.api.query.list_relays().await {
Ok(relays) => Ok(ok(StatusCode::OK, relays)),
Err(e) => Ok(err(
StatusCode::INTERNAL_SERVER_ERROR,
@@ -400,7 +403,7 @@ async fn list_tenant_relays(
let auth = state.api.extract_auth_pubkey(&headers)?;
state.api.require_admin_or_tenant(&auth, &pubkey)?;
match state.api.repo.list_relays_for_tenant(&pubkey).await {
match state.api.query.list_relays_for_tenant(&pubkey).await {
Ok(relays) => Ok(ok(StatusCode::OK, relays)),
Err(e) => Ok(err(
StatusCode::INTERNAL_SERVER_ERROR,
@@ -417,7 +420,7 @@ async fn get_relay(
) -> std::result::Result<Response, ApiError> {
let auth = state.api.extract_auth_pubkey(&headers)?;
let relay = match state.api.repo.get_relay(&id).await {
let relay = match state.api.query.get_relay(&id).await {
Ok(Some(r)) => r,
Ok(None) => return Ok(err(StatusCode::NOT_FOUND, "not-found", "relay not found")),
Err(e) => {
@@ -441,7 +444,7 @@ async fn list_relay_activity(
) -> std::result::Result<Response, ApiError> {
let auth = state.api.extract_auth_pubkey(&headers)?;
let relay = match state.api.repo.get_relay(&id).await {
let relay = match state.api.query.get_relay(&id).await {
Ok(Some(r)) => r,
Ok(None) => return Ok(err(StatusCode::NOT_FOUND, "not-found", "relay not found")),
Err(e) => return Ok(err(StatusCode::INTERNAL_SERVER_ERROR, "internal", &e.to_string())),
@@ -449,7 +452,7 @@ async fn list_relay_activity(
state.api.require_admin_or_tenant(&auth, &relay.tenant)?;
match state.api.repo.list_activity_for_relay(&id).await {
match state.api.query.list_activity_for_relay(&id).await {
Ok(activity) => Ok(ok(StatusCode::OK, serde_json::json!({ "activity": activity }))),
Err(e) => Ok(err(StatusCode::INTERNAL_SERVER_ERROR, "internal", &e.to_string())),
}
@@ -502,7 +505,7 @@ async fn create_relay(
}
};
match state.api.repo.create_relay(&relay).await {
match state.api.command.create_relay(&relay).await {
Ok(()) => Ok(ok(StatusCode::CREATED, relay)),
Err(e) => {
if matches!(map_unique_error(&e), Some("subdomain-exists")) {
@@ -530,7 +533,7 @@ async fn update_relay(
) -> std::result::Result<Response, ApiError> {
let auth = state.api.extract_auth_pubkey(&headers)?;
let mut relay = match state.api.repo.get_relay(&id).await {
let mut relay = match state.api.query.get_relay(&id).await {
Ok(Some(r)) => r,
Ok(None) => return Ok(err(StatusCode::NOT_FOUND, "not-found", "relay not found")),
Err(e) => {
@@ -599,7 +602,7 @@ async fn update_relay(
}
};
match state.api.repo.update_relay(&relay).await {
match state.api.command.update_relay(&relay).await {
Ok(()) => Ok(ok(StatusCode::OK, relay)),
Err(e) => {
if matches!(map_unique_error(&e), Some("subdomain-exists")) {
@@ -626,7 +629,7 @@ async fn deactivate_relay(
) -> std::result::Result<Response, ApiError> {
let auth = state.api.extract_auth_pubkey(&headers)?;
let relay = match state.api.repo.get_relay(&id).await {
let relay = match state.api.query.get_relay(&id).await {
Ok(Some(r)) => r,
Ok(None) => return Ok(err(StatusCode::NOT_FOUND, "not-found", "relay not found")),
Err(e) => {
@@ -665,7 +668,7 @@ async fn reactivate_relay(
) -> std::result::Result<Response, ApiError> {
let auth = state.api.extract_auth_pubkey(&headers)?;
let relay = match state.api.repo.get_relay(&id).await {
let relay = match state.api.query.get_relay(&id).await {
Ok(Some(r)) => r,
Ok(None) => return Ok(err(StatusCode::NOT_FOUND, "not-found", "relay not found")),
Err(e) => {
@@ -706,7 +709,7 @@ async fn update_tenant(
let auth = state.api.extract_auth_pubkey(&headers)?;
state.api.require_admin_or_tenant(&auth, &pubkey)?;
let mut tenant = match state.api.repo.get_tenant(&pubkey).await {
let mut tenant = match state.api.query.get_tenant(&pubkey).await {
Ok(Some(t)) => t,
Ok(None) => return Ok(err(StatusCode::NOT_FOUND, "not-found", "tenant not found")),
Err(e) => {
@@ -722,7 +725,7 @@ async fn update_tenant(
tenant.nwc_url = nwc_url;
}
match state.api.repo.update_tenant(&tenant).await {
match state.api.command.update_tenant(&tenant).await {
Ok(()) => Ok(ok(StatusCode::OK, tenant)),
Err(e) => Ok(err(
StatusCode::INTERNAL_SERVER_ERROR,
@@ -731,4 +734,3 @@ async fn update_tenant(
)),
}
}
+11 -8
View File
@@ -1,40 +1,43 @@
use anyhow::Result;
use crate::repo::Repo;
use crate::command::Command;
use crate::query::Query;
use crate::robot::Robot;
#[derive(Clone)]
pub struct Billing {
nwc_url: String,
repo: Repo,
query: Query,
command: Command,
robot: Robot,
}
impl Billing {
pub fn new(repo: Repo, robot: Robot) -> Self {
pub fn new(query: Query, command: Command, robot: Robot) -> Self {
let nwc_url = std::env::var("NWC_URL").unwrap_or_default();
Self {
nwc_url,
repo,
query,
command,
robot,
}
}
pub async fn deactivate_relay(&self, relay_id: &str) -> Result<()> {
let relay = self
.repo
.query
.get_relay(relay_id)
.await?
.ok_or_else(|| anyhow::anyhow!("relay not found"))?;
self.repo.deactivate_relay(&relay).await
self.command.deactivate_relay(&relay).await
}
pub async fn reactivate_relay(&self, relay_id: &str) -> Result<()> {
let relay = self
.repo
.query
.get_relay(relay_id)
.await?
.ok_or_else(|| anyhow::anyhow!("relay not found"))?;
self.repo.activate_relay(&relay).await
self.command.activate_relay(&relay).await
}
}
+206
View File
@@ -0,0 +1,206 @@
use anyhow::Result;
use sqlx::{Sqlite, SqlitePool, Transaction};
use crate::models::{Relay, Tenant};
#[derive(Clone)]
pub struct Command {
pool: SqlitePool,
}
impl Command {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
async fn insert_activity(
tx: &mut Transaction<'_, Sqlite>,
activity_type: &str,
resource_type: &str,
resource_id: &str,
) -> Result<()> {
let tenant = match resource_type {
"tenant" => resource_id.to_string(),
"relay" => {
sqlx::query_scalar::<_, String>("SELECT tenant FROM relay WHERE id = ?")
.bind(resource_id)
.fetch_one(&mut **tx)
.await?
}
_ => anyhow::bail!("unknown resource_type: {}", resource_type),
};
sqlx::query(
"INSERT INTO activity (id, tenant, created_at, activity_type, resource_type, resource_id)
VALUES (?, ?, strftime('%s','now'), ?, ?, ?)",
)
.bind(uuid::Uuid::new_v4().to_string())
.bind(tenant)
.bind(activity_type)
.bind(resource_type)
.bind(resource_id)
.execute(&mut **tx)
.await?;
Ok(())
}
pub async fn create_tenant(&self, tenant: &Tenant) -> Result<()> {
let mut tx = self.pool.begin().await?;
sqlx::query(
"INSERT INTO tenant (pubkey, nwc_url, created_at)
VALUES (?, ?, ?)",
)
.bind(&tenant.pubkey)
.bind(&tenant.nwc_url)
.bind(tenant.created_at)
.execute(&mut *tx)
.await?;
Self::insert_activity(&mut tx, "create_tenant", "tenant", &tenant.pubkey).await?;
tx.commit().await?;
Ok(())
}
pub async fn update_tenant(&self, tenant: &Tenant) -> Result<()> {
let mut tx = self.pool.begin().await?;
sqlx::query("UPDATE tenant SET nwc_url = ? WHERE pubkey = ?")
.bind(&tenant.nwc_url)
.bind(&tenant.pubkey)
.execute(&mut *tx)
.await?;
Self::insert_activity(&mut tx, "update_tenant", "tenant", &tenant.pubkey).await?;
tx.commit().await?;
Ok(())
}
pub async fn create_relay(&self, relay: &Relay) -> Result<()> {
let mut tx = self.pool.begin().await?;
sqlx::query(
"INSERT INTO relay (
id, tenant, schema, subdomain, plan, status, sync_error,
info_name, info_icon, info_description,
policy_public_join, policy_strip_signatures,
groups_enabled, management_enabled, blossom_enabled,
livekit_enabled, push_enabled
) VALUES (?, ?, ?, ?, ?, 'new', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(&relay.id)
.bind(&relay.tenant)
.bind(&relay.schema)
.bind(&relay.subdomain)
.bind(&relay.plan)
.bind(&relay.sync_error)
.bind(&relay.info_name)
.bind(&relay.info_icon)
.bind(&relay.info_description)
.bind(relay.policy_public_join)
.bind(relay.policy_strip_signatures)
.bind(relay.groups_enabled)
.bind(relay.management_enabled)
.bind(relay.blossom_enabled)
.bind(relay.livekit_enabled)
.bind(relay.push_enabled)
.execute(&mut *tx)
.await?;
Self::insert_activity(&mut tx, "create_relay", "relay", &relay.id).await?;
tx.commit().await?;
Ok(())
}
pub async fn update_relay(&self, relay: &Relay) -> Result<()> {
let mut tx = self.pool.begin().await?;
sqlx::query(
"UPDATE relay
SET tenant = ?, schema = ?, subdomain = ?, plan = ?, status = ?, sync_error = ?,
info_name = ?, info_icon = ?, info_description = ?,
policy_public_join = ?, policy_strip_signatures = ?,
groups_enabled = ?, management_enabled = ?, blossom_enabled = ?,
livekit_enabled = ?, push_enabled = ?
WHERE id = ?",
)
.bind(&relay.tenant)
.bind(&relay.schema)
.bind(&relay.subdomain)
.bind(&relay.plan)
.bind(&relay.status)
.bind(&relay.sync_error)
.bind(&relay.info_name)
.bind(&relay.info_icon)
.bind(&relay.info_description)
.bind(relay.policy_public_join)
.bind(relay.policy_strip_signatures)
.bind(relay.groups_enabled)
.bind(relay.management_enabled)
.bind(relay.blossom_enabled)
.bind(relay.livekit_enabled)
.bind(relay.push_enabled)
.bind(&relay.id)
.execute(&mut *tx)
.await?;
Self::insert_activity(&mut tx, "update_relay", "relay", &relay.id).await?;
tx.commit().await?;
Ok(())
}
pub async fn deactivate_relay(&self, relay: &Relay) -> Result<()> {
let mut tx = self.pool.begin().await?;
sqlx::query("UPDATE relay SET status = 'inactive' WHERE id = ?")
.bind(&relay.id)
.execute(&mut *tx)
.await?;
Self::insert_activity(&mut tx, "deactivate_relay", "relay", &relay.id).await?;
tx.commit().await?;
Ok(())
}
pub async fn activate_relay(&self, relay: &Relay) -> Result<()> {
let mut tx = self.pool.begin().await?;
sqlx::query("UPDATE relay SET status = 'active' WHERE id = ?")
.bind(&relay.id)
.execute(&mut *tx)
.await?;
Self::insert_activity(&mut tx, "activate_relay", "relay", &relay.id).await?;
tx.commit().await?;
Ok(())
}
pub async fn fail_relay_sync(&self, relay: &Relay, sync_error: String) -> Result<()> {
let mut tx = self.pool.begin().await?;
sqlx::query("UPDATE relay SET status = 'inactive', sync_error = ? WHERE id = ?")
.bind(&sync_error)
.bind(&relay.id)
.execute(&mut *tx)
.await?;
Self::insert_activity(&mut tx, "fail_relay_sync", "relay", &relay.id).await?;
tx.commit().await?;
Ok(())
}
pub async fn mark_relay_synced(&self, relay_id: &str) -> Result<()> {
sqlx::query("UPDATE relay SET synced = 1, status = 'active', sync_error = '' WHERE id = ?")
.bind(relay_id)
.execute(&self.pool)
.await?;
Ok(())
}
}
+12 -9
View File
@@ -2,7 +2,8 @@ use anyhow::Result;
use nostr_sdk::prelude::*;
use tokio::sync::Mutex;
use crate::repo::Repo;
use crate::command::Command;
use crate::query::Query;
#[derive(Clone)]
pub struct Infra {
@@ -12,12 +13,13 @@ pub struct Infra {
livekit_api_key: String,
livekit_api_secret: String,
api_secret: String,
repo: Repo,
query: Query,
command: Command,
last_activity_at: std::sync::Arc<Mutex<i64>>,
}
impl Infra {
pub fn new(repo: Repo) -> Self {
pub fn new(query: Query, command: Command) -> Self {
let api_url = std::env::var("ZOOID_API_URL").unwrap_or_default();
let relay_domain = std::env::var("RELAY_DOMAIN").unwrap_or_default();
let livekit_url = std::env::var("LIVEKIT_URL").unwrap_or_default();
@@ -31,14 +33,15 @@ impl Infra {
livekit_api_key,
livekit_api_secret,
api_secret,
repo,
query,
command,
last_activity_at: std::sync::Arc::new(Mutex::new(0)),
}
}
pub async fn start(self) {
// Initialize from DB so we don't replay historical activities on restart
match self.repo.max_activity_at().await {
match self.query.max_activity_at().await {
Ok(ts) => *self.last_activity_at.lock().await = ts,
Err(e) => tracing::error!(error = %e, "failed to read max activity timestamp"),
}
@@ -55,7 +58,7 @@ impl Infra {
pub async fn tick(&self) -> Result<()> {
let mut since_guard = self.last_activity_at.lock().await;
let since = *since_guard;
let activity = self.repo.list_activity(&since).await?;
let activity = self.query.list_activity(&since).await?;
for a in activity {
let needs_sync = matches!(
@@ -64,7 +67,7 @@ impl Infra {
);
if needs_sync {
let Some(relay) = self.repo.get_relay(&a.resource_id).await? else {
let Some(relay) = self.query.get_relay(&a.resource_id).await? else {
continue;
};
@@ -73,11 +76,11 @@ impl Infra {
match self.sync_relay(&relay, is_new).await {
Ok(()) => {
tracing::info!(relay = %relay.id, "relay sync succeeded");
self.repo.mark_relay_synced(&relay.id).await?
self.command.mark_relay_synced(&relay.id).await?
}
Err(e) => {
tracing::warn!(relay = %relay.id, error = %e, "relay sync failed");
self.repo.fail_relay_sync(&relay, e.to_string()).await?;
self.command.fail_relay_sync(&relay, e.to_string()).await?;
}
}
}
+3 -1
View File
@@ -1,6 +1,8 @@
pub mod api;
pub mod billing;
pub mod command;
pub mod infra;
pub mod models;
pub mod repo;
pub mod pool;
pub mod query;
pub mod robot;
+11 -6
View File
@@ -1,8 +1,10 @@
mod api;
mod billing;
mod command;
mod infra;
mod models;
mod repo;
mod query;
mod pool;
mod robot;
use anyhow::Result;
@@ -12,8 +14,9 @@ use tower_http::cors::{AllowOrigin, CorsLayer};
use crate::api::Api;
use crate::billing::Billing;
use crate::command::Command;
use crate::infra::Infra;
use crate::repo::Repo;
use crate::query::Query;
use crate::robot::Robot;
#[tokio::main]
@@ -25,11 +28,13 @@ async fn main() -> Result<()> {
.with(tracing_subscriber::fmt::layer())
.init();
let repo = Repo::new().await?;
let pool = pool::create_pool().await?;
let robot = Robot::new().await?;
let billing = Billing::new(repo.clone(), robot.clone());
let infra = Infra::new(repo.clone());
let api = Api::new(repo, billing.clone());
let query = Query::new(pool.clone());
let command = Command::new(pool);
let billing = Billing::new(query.clone(), command.clone(), robot.clone());
let infra = Infra::new(query.clone(), command.clone());
let api = Api::new(query, command, billing.clone());
let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
let port: u16 = std::env::var("PORT")
+51
View File
@@ -0,0 +1,51 @@
use std::path::Path;
use std::str::FromStr;
use anyhow::Result;
use sqlx::{
SqlitePool,
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
};
pub async fn create_pool() -> Result<SqlitePool> {
let raw_database_url = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| format!("sqlite://{}/data/caravel.db", env!("CARGO_MANIFEST_DIR")));
let database_url = normalize_sqlite_url(&raw_database_url);
if let Some(path) = database_url.strip_prefix("sqlite://")
&& !path.is_empty()
&& path != ":memory:"
&& let Some(parent) = Path::new(path).parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)?;
}
let connect_options =
SqliteConnectOptions::from_str(&database_url)?.create_if_missing(true);
let pool = SqlitePoolOptions::new()
.max_connections(5)
.connect_with(connect_options)
.await?;
sqlx::query("PRAGMA journal_mode = WAL;")
.execute(&pool)
.await?;
sqlx::migrate!("./migrations").run(&pool).await?;
Ok(pool)
}
fn normalize_sqlite_url(url: &str) -> String {
let Some(path) = url.strip_prefix("sqlite://") else {
return url.to_string();
};
if path.is_empty() || path == ":memory:" || Path::new(path).is_absolute() {
return url.to_string();
}
format!("sqlite://{}/{}", env!("CARGO_MANIFEST_DIR"), path)
}
+150
View File
@@ -0,0 +1,150 @@
use anyhow::Result;
use sqlx::SqlitePool;
use crate::models::{Activity, Plan, Relay, Tenant};
#[derive(Clone)]
pub struct Query {
pool: SqlitePool,
}
impl Query {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
pub async fn list_tenants(&self) -> Result<Vec<Tenant>> {
let rows = sqlx::query_as::<_, Tenant>(
"SELECT pubkey, nwc_url, created_at
FROM tenant
ORDER BY pubkey",
)
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
pub async fn get_tenant(&self, pubkey: &str) -> Result<Option<Tenant>> {
let row = sqlx::query_as::<_, Tenant>(
"SELECT pubkey, nwc_url, created_at
FROM tenant
WHERE pubkey = ?",
)
.bind(pubkey)
.fetch_optional(&self.pool)
.await?;
Ok(row)
}
pub fn list_plans() -> Vec<Plan> {
vec![
Plan {
id: "free".to_string(),
name: "Free".to_string(),
sats: 0,
members: Some(10),
blossom: false,
livekit: false,
},
Plan {
id: "basic".to_string(),
name: "Basic".to_string(),
sats: 10_000,
members: Some(100),
blossom: true,
livekit: true,
},
Plan {
id: "growth".to_string(),
name: "Growth".to_string(),
sats: 50_000,
members: None,
blossom: true,
livekit: true,
},
]
}
pub async fn list_relays(&self) -> Result<Vec<Relay>> {
let rows = sqlx::query_as::<_, Relay>(
"SELECT id, tenant, schema, subdomain, plan, status, sync_error,
info_name, info_icon, info_description,
policy_public_join, policy_strip_signatures,
groups_enabled, management_enabled, blossom_enabled,
livekit_enabled, push_enabled, synced
FROM relay
ORDER BY id",
)
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
pub async fn list_relays_for_tenant(&self, tenant_id: &str) -> Result<Vec<Relay>> {
let rows = sqlx::query_as::<_, Relay>(
"SELECT id, tenant, schema, subdomain, plan, status, sync_error,
info_name, info_icon, info_description,
policy_public_join, policy_strip_signatures,
groups_enabled, management_enabled, blossom_enabled,
livekit_enabled, push_enabled, synced
FROM relay
WHERE tenant = ?
ORDER BY id",
)
.bind(tenant_id)
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
pub async fn get_relay(&self, id: &str) -> Result<Option<Relay>> {
let row = sqlx::query_as::<_, Relay>(
"SELECT id, tenant, schema, subdomain, plan, status, sync_error,
info_name, info_icon, info_description,
policy_public_join, policy_strip_signatures,
groups_enabled, management_enabled, blossom_enabled,
livekit_enabled, push_enabled, synced
FROM relay
WHERE id = ?",
)
.bind(id)
.fetch_optional(&self.pool)
.await?;
Ok(row)
}
pub async fn max_activity_at(&self) -> Result<i64> {
let val = sqlx::query_scalar::<_, Option<i64>>(
"SELECT MAX(created_at) FROM activity",
)
.fetch_one(&self.pool)
.await?;
Ok(val.unwrap_or(0))
}
pub async fn list_activity(&self, since: &i64) -> Result<Vec<Activity>> {
let rows = sqlx::query_as::<_, Activity>(
"SELECT id, tenant, created_at, activity_type, resource_type, resource_id
FROM activity
WHERE created_at > ?
ORDER BY created_at, id",
)
.bind(since)
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
pub async fn list_activity_for_relay(&self, relay_id: &str) -> Result<Vec<Activity>> {
let rows = sqlx::query_as::<_, Activity>(
"SELECT id, tenant, created_at, activity_type, resource_type, resource_id
FROM activity
WHERE resource_type = 'relay' AND resource_id = ?
ORDER BY created_at DESC, id DESC",
)
.bind(relay_id)
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
}
-372
View File
@@ -1,372 +0,0 @@
use std::path::Path;
use std::str::FromStr;
use anyhow::Result;
use sqlx::{
Sqlite, SqlitePool, Transaction,
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
};
use crate::models::{Activity, Plan, Relay, Tenant};
#[derive(Clone)]
pub struct Repo {
pub pool: SqlitePool,
}
impl Repo {
pub async fn new() -> Result<Self> {
let raw_database_url = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| format!("sqlite://{}/data/caravel.db", env!("CARGO_MANIFEST_DIR")));
let database_url = normalize_sqlite_url(&raw_database_url);
if let Some(path) = database_url.strip_prefix("sqlite://")
&& !path.is_empty()
&& path != ":memory:"
&& let Some(parent) = Path::new(path).parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)?;
}
let connect_options =
SqliteConnectOptions::from_str(&database_url)?.create_if_missing(true);
let pool = SqlitePoolOptions::new()
.max_connections(5)
.connect_with(connect_options)
.await?;
sqlx::query("PRAGMA journal_mode = WAL;")
.execute(&pool)
.await?;
sqlx::migrate!("./migrations").run(&pool).await?;
Ok(Self { pool })
}
async fn insert_activity(
tx: &mut Transaction<'_, Sqlite>,
activity_type: &str,
resource_type: &str,
resource_id: &str,
) -> Result<()> {
let tenant = match resource_type {
"tenant" => resource_id.to_string(),
"relay" => {
sqlx::query_scalar::<_, String>("SELECT tenant FROM relay WHERE id = ?")
.bind(resource_id)
.fetch_one(&mut **tx)
.await?
}
_ => anyhow::bail!("unknown resource_type: {}", resource_type),
};
sqlx::query(
"INSERT INTO activity (id, tenant, created_at, activity_type, resource_type, resource_id)
VALUES (?, ?, strftime('%s','now'), ?, ?, ?)",
)
.bind(uuid::Uuid::new_v4().to_string())
.bind(tenant)
.bind(activity_type)
.bind(resource_type)
.bind(resource_id)
.execute(&mut **tx)
.await?;
Ok(())
}
pub async fn list_tenants(&self) -> Result<Vec<Tenant>> {
let rows = sqlx::query_as::<_, Tenant>(
"SELECT pubkey, nwc_url, created_at
FROM tenant
ORDER BY pubkey",
)
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
pub async fn get_tenant(&self, pubkey: &str) -> Result<Option<Tenant>> {
let row = sqlx::query_as::<_, Tenant>(
"SELECT pubkey, nwc_url, created_at
FROM tenant
WHERE pubkey = ?",
)
.bind(pubkey)
.fetch_optional(&self.pool)
.await?;
Ok(row)
}
pub async fn create_tenant(&self, tenant: &Tenant) -> Result<()> {
let mut tx = self.pool.begin().await?;
sqlx::query(
"INSERT INTO tenant (pubkey, nwc_url, created_at)
VALUES (?, ?, ?)",
)
.bind(&tenant.pubkey)
.bind(&tenant.nwc_url)
.bind(tenant.created_at)
.execute(&mut *tx)
.await?;
Self::insert_activity(&mut tx, "create_tenant", "tenant", &tenant.pubkey).await?;
tx.commit().await?;
Ok(())
}
pub async fn update_tenant(&self, tenant: &Tenant) -> Result<()> {
let mut tx = self.pool.begin().await?;
sqlx::query("UPDATE tenant SET nwc_url = ? WHERE pubkey = ?")
.bind(&tenant.nwc_url)
.bind(&tenant.pubkey)
.execute(&mut *tx)
.await?;
Self::insert_activity(&mut tx, "update_tenant", "tenant", &tenant.pubkey).await?;
tx.commit().await?;
Ok(())
}
pub async fn list_relays(&self) -> Result<Vec<Relay>> {
let rows = sqlx::query_as::<_, Relay>(
"SELECT id, tenant, schema, subdomain, plan, status, sync_error,
info_name, info_icon, info_description,
policy_public_join, policy_strip_signatures,
groups_enabled, management_enabled, blossom_enabled,
livekit_enabled, push_enabled, synced
FROM relay
ORDER BY id",
)
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
pub async fn list_relays_for_tenant(&self, tenant_id: &str) -> Result<Vec<Relay>> {
let rows = sqlx::query_as::<_, Relay>(
"SELECT id, tenant, schema, subdomain, plan, status, sync_error,
info_name, info_icon, info_description,
policy_public_join, policy_strip_signatures,
groups_enabled, management_enabled, blossom_enabled,
livekit_enabled, push_enabled, synced
FROM relay
WHERE tenant = ?
ORDER BY id",
)
.bind(tenant_id)
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
pub async fn get_relay(&self, id: &str) -> Result<Option<Relay>> {
let row = sqlx::query_as::<_, Relay>(
"SELECT id, tenant, schema, subdomain, plan, status, sync_error,
info_name, info_icon, info_description,
policy_public_join, policy_strip_signatures,
groups_enabled, management_enabled, blossom_enabled,
livekit_enabled, push_enabled, synced
FROM relay
WHERE id = ?",
)
.bind(id)
.fetch_optional(&self.pool)
.await?;
Ok(row)
}
pub async fn create_relay(&self, relay: &Relay) -> Result<()> {
let mut tx = self.pool.begin().await?;
sqlx::query(
"INSERT INTO relay (
id, tenant, schema, subdomain, plan, status, sync_error,
info_name, info_icon, info_description,
policy_public_join, policy_strip_signatures,
groups_enabled, management_enabled, blossom_enabled,
livekit_enabled, push_enabled
) VALUES (?, ?, ?, ?, ?, 'new', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(&relay.id)
.bind(&relay.tenant)
.bind(&relay.schema)
.bind(&relay.subdomain)
.bind(&relay.plan)
.bind(&relay.sync_error)
.bind(&relay.info_name)
.bind(&relay.info_icon)
.bind(&relay.info_description)
.bind(relay.policy_public_join)
.bind(relay.policy_strip_signatures)
.bind(relay.groups_enabled)
.bind(relay.management_enabled)
.bind(relay.blossom_enabled)
.bind(relay.livekit_enabled)
.bind(relay.push_enabled)
.execute(&mut *tx)
.await?;
Self::insert_activity(&mut tx, "create_relay", "relay", &relay.id).await?;
tx.commit().await?;
Ok(())
}
pub async fn update_relay(&self, relay: &Relay) -> Result<()> {
let mut tx = self.pool.begin().await?;
sqlx::query(
"UPDATE relay
SET tenant = ?, schema = ?, subdomain = ?, plan = ?, status = ?, sync_error = ?,
info_name = ?, info_icon = ?, info_description = ?,
policy_public_join = ?, policy_strip_signatures = ?,
groups_enabled = ?, management_enabled = ?, blossom_enabled = ?,
livekit_enabled = ?, push_enabled = ?
WHERE id = ?",
)
.bind(&relay.tenant)
.bind(&relay.schema)
.bind(&relay.subdomain)
.bind(&relay.plan)
.bind(&relay.status)
.bind(&relay.sync_error)
.bind(&relay.info_name)
.bind(&relay.info_icon)
.bind(&relay.info_description)
.bind(relay.policy_public_join)
.bind(relay.policy_strip_signatures)
.bind(relay.groups_enabled)
.bind(relay.management_enabled)
.bind(relay.blossom_enabled)
.bind(relay.livekit_enabled)
.bind(relay.push_enabled)
.bind(&relay.id)
.execute(&mut *tx)
.await?;
Self::insert_activity(&mut tx, "update_relay", "relay", &relay.id).await?;
tx.commit().await?;
Ok(())
}
pub async fn deactivate_relay(&self, relay: &Relay) -> Result<()> {
let mut tx = self.pool.begin().await?;
sqlx::query("UPDATE relay SET status = 'inactive' WHERE id = ?")
.bind(&relay.id)
.execute(&mut *tx)
.await?;
Self::insert_activity(&mut tx, "deactivate_relay", "relay", &relay.id).await?;
tx.commit().await?;
Ok(())
}
pub async fn fail_relay_sync(&self, relay: &Relay, sync_error: String) -> Result<()> {
let mut tx = self.pool.begin().await?;
sqlx::query("UPDATE relay SET status = 'inactive', sync_error = ? WHERE id = ?")
.bind(&sync_error)
.bind(&relay.id)
.execute(&mut *tx)
.await?;
Self::insert_activity(&mut tx, "fail_relay_sync", "relay", &relay.id).await?;
tx.commit().await?;
Ok(())
}
pub async fn mark_relay_synced(&self, relay_id: &str) -> Result<()> {
sqlx::query("UPDATE relay SET synced = 1, status = 'active', sync_error = '' WHERE id = ?")
.bind(relay_id)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn max_activity_at(&self) -> Result<i64> {
let val = sqlx::query_scalar::<_, Option<i64>>(
"SELECT MAX(created_at) FROM activity",
)
.fetch_one(&self.pool)
.await?;
Ok(val.unwrap_or(0))
}
pub async fn list_activity(&self, since: &i64) -> Result<Vec<Activity>> {
let rows = sqlx::query_as::<_, Activity>(
"SELECT id, tenant, created_at, activity_type, resource_type, resource_id
FROM activity
WHERE created_at > ?
ORDER BY created_at, id",
)
.bind(since)
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
pub async fn list_activity_for_relay(&self, relay_id: &str) -> Result<Vec<Activity>> {
let rows = sqlx::query_as::<_, Activity>(
"SELECT id, tenant, created_at, activity_type, resource_type, resource_id
FROM activity
WHERE resource_type = 'relay' AND resource_id = ?
ORDER BY created_at DESC, id DESC",
)
.bind(relay_id)
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
pub fn list_plans() -> Vec<Plan> {
vec![
Plan {
id: "free".to_string(),
name: "Free".to_string(),
sats: 0,
members: Some(10),
blossom: false,
livekit: false,
},
Plan {
id: "basic".to_string(),
name: "Basic".to_string(),
sats: 10_000,
members: Some(100),
blossom: true,
livekit: true,
},
Plan {
id: "growth".to_string(),
name: "Growth".to_string(),
sats: 50_000,
members: None,
blossom: true,
livekit: true,
},
]
}
}
fn normalize_sqlite_url(url: &str) -> String {
let Some(path) = url.strip_prefix("sqlite://") else {
return url.to_string();
};
if path.is_empty() || path == ":memory:" || Path::new(path).is_absolute() {
return url.to_string();
}
format!("sqlite://{}/{}", env!("CARGO_MANIFEST_DIR"), path)
}
+1 -1
View File
@@ -1,3 +1,3 @@
- [ ] Split repo into queries and commands
- [ ] Update infra to listen to sqlite
- [ ] Fix billing by using stripe as a backend to do proration, then mark invoices paid manually when using bitcoin.
- [ ] Send a payment link instead of an invoice so we can generate/pay on the fly