This commit is contained in:
Kelorastus
2026-09-16 11:42:24 +02:00
parent b7f3061be3
commit 3fdfde7043
18 changed files with 1485 additions and 21 deletions
+24
View File
@@ -7,6 +7,7 @@ use tauri_plugin_opener::OpenerExt;
use crate::launcher::{Launcher, Status};
use crate::settings::Settings;
use crate::stack::StackEvent;
use crate::sync::Conflict;
type CmdResult<T> = Result<T, String>;
@@ -89,6 +90,29 @@ pub async fn get_stack(launcher: State<'_, Launcher>) -> CmdResult<Vec<StackEven
Ok(launcher.stack_states())
}
#[tauri::command]
pub async fn sync_now(app: AppHandle, launcher: State<'_, Launcher>) -> CmdResult<()> {
launcher.sync_now(&app).await
}
#[tauri::command]
pub async fn get_sync_conflicts(app: AppHandle, launcher: State<'_, Launcher>) -> CmdResult<Vec<Conflict>> {
launcher.sync_conflicts(&app).await
}
/// `choice`: "theirs", "mine", "value" (with `value`) or "dismiss" (refused edits only).
#[tauri::command]
pub async fn resolve_sync_conflict(
app: AppHandle,
launcher: State<'_, Launcher>,
tbl: String,
key: serde_json::Value,
choice: String,
value: Option<serde_json::Value>,
) -> CmdResult<Vec<Conflict>> {
launcher.resolve_sync_conflict(&app, tbl, key, choice, value).await
}
#[tauri::command]
pub async fn get_settings(launcher: State<'_, Launcher>) -> CmdResult<Settings> {
Ok(launcher.settings().await)
+356 -2
View File
@@ -1,6 +1,7 @@
//! The launcher's live state behind every command, and the `status` event.
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
@@ -15,7 +16,8 @@ use crate::localdb::{self, Pg};
use crate::settings::{normalize_url, Settings};
use crate::site::{self, SessionRefresh, TokenCheck};
use crate::stack::{self, Layout, RunningStack, StackConfig, StackEvent};
use crate::state::{self, LauncherState};
use crate::state::{self, LauncherState, SyncOrigin};
use crate::sync::{self, SyncError, SyncStatus};
use crate::tokens::{self, SiteToken};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
@@ -26,6 +28,8 @@ pub enum Phase {
SigningIn,
Installing,
Restoring,
/// Exchanging operator data with the DHP (ODIAN is not running meanwhile).
Syncing,
Launching,
Running,
Error,
@@ -50,6 +54,11 @@ pub struct Status {
pub phase: Phase,
pub error: Option<String>,
pub install_dir: String,
/// Operator-data sync counts from the last sync this session; None before one.
pub sync: Option<SyncStatus>,
/// While ODIAN runs: true when its data is coming from the DHP, false when from
/// this PC. None when ODIAN is not running, or the DHP offers no Engine API.
pub live: Option<bool>,
}
#[derive(Clone, Serialize)]
@@ -90,6 +99,11 @@ struct Inner {
token: Option<SiteToken>,
phase: Phase,
error: Option<String>,
sync: Option<SyncStatus>,
/// The DHP's Engine API, learned from its health answer; kept once seen so a launch
/// during a blip still knows where live mode will forward.
engine_url: Option<String>,
live: Option<bool>,
}
impl Inner {
@@ -111,6 +125,8 @@ impl Inner {
phase: self.phase,
error: self.error.clone(),
install_dir: self.settings.install_dir.clone(),
sync: self.sync.clone(),
live: self.live,
}
}
}
@@ -140,7 +156,7 @@ impl Launcher {
.map_err(|e| format!("cannot start the HTTP client: {e}"))?;
Ok(Launcher {
http,
inner: Mutex::new(Inner { settings, state, online: false, available: None, token, phase: Phase::Checking, error: None }),
inner: Mutex::new(Inner { settings, state, online: false, available: None, token, phase: Phase::Checking, error: None, sync: None, engine_url: None, live: None }),
stack: Mutex::new(None),
stack_states: std::sync::Mutex::new(Vec::new()),
sign_in_cancel: Notify::new(),
@@ -253,6 +269,10 @@ impl Launcher {
let mut inner = self.inner.lock().await;
inner.online = probe.online;
inner.available = probe.version;
// Keep the last known address while offline: it is where live mode resumes.
if probe.engine_url.is_some() {
inner.engine_url = probe.engine_url;
}
}
// Offline nothing is checked or forgotten: the remembered operator still launches.
if let (true, Some(token)) = (probe.online, token) {
@@ -604,6 +624,8 @@ impl Launcher {
restored.map_err(|e| format!("The database restore failed: {e}"))?;
let mut inner = self.inner.lock().await;
inner.state.db_snapshot = Some(db.snapshot.clone());
inner.state.sync_origin =
db.sync.as_ref().map(|origin| SyncOrigin { db_id: origin.db_id.clone(), horizon: origin.horizon.clone() });
inner.state.save(install_dir)?;
}
@@ -682,6 +704,7 @@ impl Launcher {
pg_password: inner.state.pg_password.clone(),
install_id: inner.state.install_id.clone(),
tile_sets,
upstream_url: inner.engine_url.clone(),
}
};
if let Ok(mut states) = self.stack_states.lock() {
@@ -697,6 +720,19 @@ impl Launcher {
}
async fn run_stack(&self, app: &AppHandle, config: StackConfig) {
// Take in the other operators' work before the API loads it. Never blocks the launch.
self.set_phase(app, Phase::Syncing).await;
self.sync_run(app, &config.layout, &config.pg_password, &config.install_id, true).await;
self.set_phase(app, Phase::Launching).await;
// Live mode: hand the API the DHP token before it starts, so a launch made online
// comes up on the DHP's data instead of switching a moment later.
let live_mode = config.upstream_url.is_some();
if live_mode {
let token = self.site_access_token(app).await.ok();
self.write_upstream_token(&config.layout, token.as_deref());
}
let emit = |event: StackEvent| self.record_stack(app, event);
let running = match stack::start(&self.http, &config, &emit).await {
Ok(running) => running,
@@ -709,14 +745,31 @@ impl Launcher {
*self.stack.lock().await = Some(running);
self.set_phase(app, Phase::Running).await;
let live_cancel = CancellationToken::new();
if live_mode {
let handle = app.clone();
let cancel = live_cancel.clone();
let (layout, password, install_id) =
(config.layout.clone(), config.pg_password.clone(), config.install_id.clone());
tauri::async_runtime::spawn(async move {
handle.state::<Launcher>().live_loop(&handle, layout, password, install_id, cancel).await;
});
}
if let Some(odian) = odian {
stack::wait_exit(&odian).await;
}
// Odian.exe closed. If Stop already took the stack, it stopped the rest too.
live_cancel.cancel();
self.write_upstream_token(&config.layout, None);
self.inner.lock().await.live = None;
let taken = self.stack.lock().await.take();
if let Some(mut running) = taken {
running.stop(&emit).await;
}
// Send this session's work while it is fresh; pulling waits for the next launch.
self.set_phase(app, Phase::Syncing).await;
self.sync_run(app, &config.layout, &config.pg_password, &config.install_id, false).await;
self.finish(app, None).await;
}
@@ -729,6 +782,307 @@ impl Launcher {
Ok(())
}
// ── operator-data sync ──────────────────────────────────────────────────
/// Sync with the DHP while the local Engine API is not running. Never fails the caller:
/// offline, signed out or refused, the edits stay on this PC for the next connected run.
async fn sync_run(&self, app: &AppHandle, layout: &Layout, pg_password: &str, install_id: &str, pull: bool) {
let sql_dir = layout.sync_sql_dir();
if !sync::schema_shipped(&sql_dir) {
return; // a version from before sync
}
let (online, site_url, origin) = {
let inner = self.inner.lock().await;
(inner.online, inner.settings.site_url.clone(), inner.state.sync_origin.clone())
};
if !online {
self.log(app, "info", "Offline: this PC's changes stay here until the next connected launch.");
return;
}
let pg = Pg::new(layout, pg_password);
let db = pg.clone();
let started = match tauri::async_runtime::spawn_blocking(move || -> Result<bool, String> {
let started = !db.running();
if started {
db.start()?;
}
Ok(started)
})
.await
{
Ok(Ok(started)) => started,
Ok(Err(message)) => {
self.log(app, "warn", format!("Sync skipped: {message}"));
return;
}
Err(e) => {
self.log(app, "warn", format!("Sync skipped: {e}"));
return;
}
};
match self.sync_with_dhp(app, &pg, &sql_dir, &site_url, install_id, origin, pull).await {
Ok(report) => {
let s = &report.status;
self.log(
app,
"info",
format!(
"Synced with the DHP: {} change(s) sent, {} received. {} waiting to send, {} conflict(s), {} refused.",
report.accepted, report.received, s.pending, s.conflicts, s.rejected
),
);
if s.conflicts + s.rejected > 0 {
self.log(app, "warn", "Some changes need your decision: review them under Sync.");
}
self.inner.lock().await.sync = Some(report.status);
}
Err(message) => self.log(app, "warn", format!("Sync did not complete: {message}")),
}
if started {
let _ = tauri::async_runtime::spawn_blocking(move || pg.stop()).await;
}
self.emit(app).await;
}
#[allow(clippy::too_many_arguments)]
async fn sync_with_dhp(
&self,
app: &AppHandle,
pg: &Pg,
sql_dir: &Path,
site_url: &str,
install_id: &str,
origin: Option<SyncOrigin>,
pull: bool,
) -> Result<sync::Report, String> {
let token = self.site_access_token(app).await?;
let (db, dir) = (pg.clone(), sql_dir.to_path_buf());
let joined = tauri::async_runtime::spawn_blocking(move || {
sync::load_schema(&db, &dir)?;
sync::joined_db_id(&db)
})
.await
.map_err(|e| e.to_string())??;
if joined.is_none() {
let (db_id, since) = match origin {
Some(origin) => (origin.db_id, origin.horizon),
None => {
let dhp = sync::Dhp { http: &self.http, site_url, token: &token };
let greeting = sync::hello(&dhp).await.map_err(|e| e.to_string())?;
self.log(app, "warn", "This PC's database predates sync: it takes the DHP's changes from now on.");
greeting
}
};
let db = pg.clone();
tauri::async_runtime::spawn_blocking(move || sync::join(&db, &db_id, &since)).await.map_err(|e| e.to_string())??;
}
let dhp = sync::Dhp { http: &self.http, site_url, token: &token };
match sync::run(pg, &dhp, install_id, pull).await {
Err(SyncError::Unauthorized) => {
let token = self.renew(app).await?;
let dhp = sync::Dhp { http: &self.http, site_url, token: &token };
sync::run(pg, &dhp, install_id, pull).await.map_err(|e| e.to_string())
}
other => other.map_err(|e| e.to_string()),
}
}
/// Give the local API the DHP token it forwards with — or take it away, which sends
/// the API back to this PC's data without restarting anything.
fn write_upstream_token(&self, layout: &Layout, token: Option<&str>) {
let path = stack::upstream_token_file(layout);
match token {
Some(token) => {
if let Err(e) = crate::fsutil::write_atomic(&path, token.as_bytes()) {
log::warn!("cannot hand the local API its DHP token: {e}");
}
}
None => {
let _ = std::fs::remove_file(&path);
}
}
}
/// Ask the local API where ODIAN's data is coming from right now.
async fn read_live_state(&self) {
let url = format!("http://127.0.0.1:{}/offline/status", stack::API_PORT);
let live = match self.http.get(url).timeout(Duration::from_secs(3)).send().await {
Ok(response) => response
.json::<serde_json::Value>()
.await
.ok()
.and_then(|body| body.get("mode").and_then(|mode| mode.as_str()).map(|mode| mode == "live")),
Err(_) => None,
};
self.inner.lock().await.live = live;
}
/// While ODIAN runs against a DHP: keep its token fresh, notice the connection coming
/// and going, send this PC's offline work the moment it returns, and keep this PC's
/// own copy warm so an abrupt drop lands on fresh data rather than an old snapshot.
async fn live_loop(
&self,
app: &AppHandle,
layout: Layout,
pg_password: String,
install_id: String,
cancel: CancellationToken,
) {
const TICK: Duration = Duration::from_secs(30);
const MIRROR_EVERY: Duration = Duration::from_secs(120);
let (site_url, mut was_online) = {
let inner = self.inner.lock().await;
(inner.settings.site_url.clone(), inner.online)
};
let mut mirrored = Instant::now();
loop {
tokio::select! {
_ = cancel.cancelled() => break,
_ = tokio::time::sleep(TICK) => {}
}
let probe = site::probe(&self.http, &site_url).await;
{
let mut inner = self.inner.lock().await;
inner.online = probe.online;
if probe.engine_url.is_some() {
inner.engine_url = probe.engine_url.clone();
}
}
if !probe.online {
if was_online {
self.log(app, "warn", "The DHP is out of reach: ODIAN is working from this PC. Your changes stay here until it is back.");
}
was_online = false;
self.read_live_state().await;
self.emit(app).await;
continue;
}
let Ok(token) = self.site_access_token(app).await else {
self.read_live_state().await;
continue;
};
if !was_online {
// Hold the API on this PC's data until this PC's offline work has reached the
// DHP: nothing may be edited there on top of edits that have not arrived yet.
self.write_upstream_token(&layout, None);
self.log(app, "info", "Back on the DHP: sending the changes made offline…");
self.sync_run(app, &layout, &pg_password, &install_id, false).await;
mirrored = Instant::now();
}
self.write_upstream_token(&layout, Some(&token));
if !was_online {
self.log(app, "info", "ODIAN is back on the DHP's data.");
was_online = true;
}
if mirrored.elapsed() >= MIRROR_EVERY {
self.sync_run(app, &layout, &pg_password, &install_id, true).await;
mirrored = Instant::now();
}
self.read_live_state().await;
self.emit(app).await;
}
}
/// Sync on demand while ODIAN is stopped.
pub async fn sync_now(&self, app: &AppHandle) -> Result<(), String> {
let (layout, password, install_id) = {
let mut inner = self.inner.lock().await;
if !matches!(inner.phase, Phase::Idle | Phase::Error) {
return Err("ODIAN is busy: sync once it has stopped.".to_string());
}
let version = inner.state.installed_version.clone().ok_or("ODIAN is not installed.")?;
if inner.state.pg_password.is_empty() || inner.state.db_snapshot.is_none() {
return Err("The local database was never set up. Reinstall from the DHP or a drive.".to_string());
}
inner.phase = Phase::Syncing;
inner.error = None;
(Layout::new(&inner.settings.install_path(), &version), inner.state.pg_password.clone(), inner.state.install_id.clone())
};
self.emit(app).await;
self.sync_run(app, &layout, &password, &install_id, true).await;
self.finish(app, None).await;
Ok(())
}
/// Run `work` on this PC's database while ODIAN is stopped, starting Postgres just for it.
async fn with_local_db<T: Send + 'static>(
&self,
app: &AppHandle,
work: impl FnOnce(&Pg) -> Result<T, String> + Send + 'static,
) -> Result<T, String> {
let (layout, password) = {
let mut inner = self.inner.lock().await;
if !matches!(inner.phase, Phase::Idle | Phase::Error) {
return Err("ODIAN is busy: review sync once it has stopped.".to_string());
}
let version = inner.state.installed_version.clone().ok_or("ODIAN is not installed.")?;
if inner.state.pg_password.is_empty() {
return Err("The local database was never set up.".to_string());
}
inner.phase = Phase::Syncing;
(Layout::new(&inner.settings.install_path(), &version), inner.state.pg_password.clone())
};
self.emit(app).await;
let pg = Pg::new(&layout, &password);
let result = tauri::async_runtime::spawn_blocking(move || {
let started = !pg.running();
if started {
pg.start()?;
}
let result = work(&pg);
if started {
let _ = pg.stop();
}
result
})
.await
.map_err(|e| e.to_string())
.and_then(|result| result);
{
let mut inner = self.inner.lock().await;
if inner.phase == Phase::Syncing {
inner.phase = Phase::Idle;
}
}
self.emit(app).await;
result
}
/// What the operator has to decide, oldest first.
pub async fn sync_conflicts(&self, app: &AppHandle) -> Result<Vec<sync::Conflict>, String> {
self
.with_local_db(app, |pg| match sync::joined_db_id(pg)? {
None => Ok(Vec::new()),
Some(_) => sync::conflicts(pg),
})
.await
}
/// Record one decision; returns what is left to decide.
pub async fn resolve_sync_conflict(
&self,
app: &AppHandle,
tbl: String,
key: serde_json::Value,
choice: String,
value: Option<serde_json::Value>,
) -> Result<Vec<sync::Conflict>, String> {
let (left, status) = self
.with_local_db(app, move |pg| {
sync::resolve(pg, &tbl, &key, &choice, value.as_ref())?;
Ok((sync::conflicts(pg)?, sync::status(pg)?))
})
.await?;
self.inner.lock().await.sync = Some(status);
self.emit(app).await;
Ok(left)
}
/// The launcher is exiting: stop the stack cleanly rather than leave Postgres to crash recovery.
pub async fn shutdown(&self) {
if let Ok(slot) = self.install_cancel.lock() {
+4
View File
@@ -10,6 +10,7 @@ mod settings;
mod site;
mod stack;
mod state;
mod sync;
mod tokens;
use tauri::Manager;
@@ -53,6 +54,9 @@ pub fn run() {
commands::get_settings,
commands::set_settings,
commands::pick_folder,
commands::sync_now,
commands::get_sync_conflicts,
commands::resolve_sync_conflict,
])
.build(tauri::generate_context!())
.expect("error while building the launcher")
+45 -10
View File
@@ -107,6 +107,11 @@ impl Pg {
if self.running() {
return Ok(());
}
// Another install's Postgres on the shared port would leave only "exit code 1" in the log.
if crate::stack::port_taken(PG_PORT) {
return Err("Another ODIAN install is already running on this PC: it holds the database port. Stop it in the launcher window that started it, then try again."
.to_string());
}
let mut cmd = self.tool("pg_ctl");
cmd
.arg("start")
@@ -151,6 +156,34 @@ impl Pg {
Self::run(cmd, "psql")
}
/// Run a script on the `odian` database through psql's stdin and return what it printed
/// (unaligned, tuples only). For sync, whose JSON payloads are too large for a command line.
pub fn script(&self, sql: &str) -> Result<String, String> {
use std::io::Write;
let port = PG_PORT.to_string();
let mut cmd = self.tool("psql");
cmd
.env("PGCLIENTENCODING", "UTF8")
.args(["-X", "-q", "-A", "-t", "-v", "ON_ERROR_STOP=1", "-h", "127.0.0.1", "-p", port.as_str()])
.args(["-U", PG_USER, "-d", PG_DATABASE, "-f", "-"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd.spawn().map_err(|e| format!("cannot run psql: {e}"))?;
let mut stdin = child.stdin.take().ok_or("cannot write to psql")?;
// Feed from a thread: psql filling its stdout pipe while we still write would deadlock.
let body = sql.as_bytes().to_vec();
let writer = std::thread::spawn(move || stdin.write_all(&body));
let out = child.wait_with_output().map_err(|e| format!("psql failed: {e}"))?;
let _ = writer.join();
if out.status.success() {
return Ok(String::from_utf8_lossy(&out.stdout).into_owned());
}
let stderr = String::from_utf8_lossy(&out.stderr);
let tail: String = stderr.chars().rev().take(1500).collect::<Vec<_>>().into_iter().rev().collect();
Err(format!("psql failed ({}): {}", out.status, tail.trim()))
}
/// Replace the `odian` database with a snapshot. Only for a PC whose restore never
/// completed: anything an operator did in the old database is dropped.
pub fn restore(
@@ -225,24 +258,26 @@ impl Pg {
.args(["-h", "127.0.0.1", "-p", port.as_str(), "-U", PG_USER, "-d", PG_DATABASE])
.args(["--no-owner", "--no-privileges", "--exit-on-error"]);
if pre_data {
cmd.arg("-L").arg(self.pre_data_list(&dump)?);
// A fresh database already has schema public; --exit-on-error would stop on the duplicate.
let drop = |line: &str| line.contains(" SCHEMA - public ") || line.contains(" COMMENT - SCHEMA public ");
cmd.arg("-L").arg(self.toc_without(&dump, "pre-data.list", &drop)?);
} else if file.section == "post-data" {
// A DHP running sync has marking triggers on its tables; they call sync.mark(), which
// this database gets only when the launcher loads the version's sync schema.
let drop = |line: &str| line.contains(" TRIGGER ") && line.contains(" sync_mark_");
cmd.arg("-L").arg(self.toc_without(&dump, "post-data.list", &drop)?);
}
cmd.arg(&dump);
Self::run(cmd, &format!("pg_restore {}", file.path)).map(|_| ())
}
/// The pre-data table of contents without `CREATE SCHEMA public`: a fresh database
/// already has that schema, and --exit-on-error would stop on the duplicate.
fn pre_data_list(&self, dump: &Path) -> Result<PathBuf, String> {
/// The dump's table of contents without the entries `drop` names, for `pg_restore -L`.
fn toc_without(&self, dump: &Path, list_name: &str, drop: &dyn Fn(&str) -> bool) -> Result<PathBuf, String> {
let mut cmd = self.tool("pg_restore");
cmd.arg("-l").arg(dump);
let listing = Self::run(cmd, "pg_restore -l")?;
let kept: String = listing
.lines()
.filter(|line| !(line.contains(" SCHEMA - public ") || line.contains(" COMMENT - SCHEMA public ")))
.map(|line| format!("{line}\n"))
.collect();
let path = self.logs.join("pre-data.list");
let kept: String = listing.lines().filter(|line| !drop(line)).map(|line| format!("{line}\n")).collect();
let path = self.logs.join(list_name);
std::fs::write(&path, kept).map_err(|e| format!("cannot write {}: {e}", path.display()))?;
Ok(path)
}
+6 -5
View File
@@ -11,10 +11,13 @@ pub struct Probe {
pub online: bool,
/// The site's CURRENT version.
pub version: Option<String>,
/// The DHP's Engine API, as this PC can reach it: where live mode forwards while
/// online. Absent when the DHP offers none, and this PC then serves its own data.
pub engine_url: Option<String>,
}
pub async fn probe(http: &reqwest::Client, site_url: &str) -> Probe {
let offline = Probe { online: false, version: None };
let offline = Probe { online: false, version: None, engine_url: None };
if site_url.is_empty() {
return offline;
}
@@ -25,10 +28,8 @@ pub async fn probe(http: &reqwest::Client, site_url: &str) -> Probe {
return offline;
}
let body: serde_json::Value = response.json().await.unwrap_or_default();
Probe {
online: true,
version: body.get("version").and_then(|v| v.as_str()).map(str::to_string),
}
let text = |key: &str| body.get(key).and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(str::to_string);
Probe { online: true, version: text("version"), engine_url: text("engine_api_url") }
}
pub enum TokenCheck {
+64 -2
View File
@@ -115,6 +115,10 @@ impl Layout {
pub fn pg_data(&self) -> PathBuf {
self.data_dir().join("pg")
}
/// The operator-data sync schema this version ships (`sync\*.sql`); absent before M3.
pub fn sync_sql_dir(&self) -> PathBuf {
self.version_dir().join("sync")
}
fn python(&self) -> PathBuf {
self.app_file(PYTHON_EXE)
}
@@ -140,6 +144,15 @@ pub struct StackConfig {
pub install_id: String,
/// (style, dataset) of every tile set the installed version carries.
pub tile_sets: Vec<(String, String)>,
/// The DHP's Engine API (from its health answer). With one, the local API serves
/// the DHP's data while it is reachable; without, this PC always serves its own.
pub upstream_url: Option<String>,
}
/// Where the launcher keeps the DHP token the local API forwards with. A file, not an
/// env var: the launcher rewrites it whenever it renews, without restarting the stack.
pub fn upstream_token_file(layout: &Layout) -> PathBuf {
layout.state_dir().join("api").join("upstream.token")
}
/// A process started from a clean environment, in `cwd`, with `search_path` first on PATH.
@@ -223,7 +236,26 @@ impl RunningStack {
}
}
/// Is something already listening on 127.0.0.1:`port`?
pub fn port_taken(port: u16) -> bool {
std::net::TcpListener::bind(("127.0.0.1", port)).is_err()
}
/// Every install on this PC uses the same three loopback ports, so only one runs at a time.
/// Said plainly here, rather than as "Postgres did not start" once the ports collide.
fn check_ports_free() -> Result<(), String> {
for (port, what) in [(PG_PORT, "the database"), (API_PORT, "the Engine API"), (TILES_PORT, "the tile server")] {
if port_taken(port) {
return Err(format!(
"Another ODIAN install is already running on this PC ({what} holds 127.0.0.1:{port}). Stop it in the launcher window that started it, then launch again."
));
}
}
Ok(())
}
pub async fn start(http: &reqwest::Client, config: &StackConfig, emit: Emit<'_>) -> Result<RunningStack, String> {
check_ports_free()?;
prepare(config)?;
let mut running = RunningStack {
job: job::Job::new()?,
@@ -413,7 +445,7 @@ fn datasets(config: &StackConfig) -> serde_json::Value {
fn api_env(config: &StackConfig) -> Vec<(&'static str, String)> {
let api = config.layout.state_dir().join("api");
let logs = api.join("logs");
vec![
let mut env = vec![
("ODIAN_API_BIND", "127.0.0.1".to_string()),
("ODIAN_API_PORT", API_PORT.to_string()),
("ODIAN_PG_INI", text(api.join("pg.ini"))),
@@ -428,7 +460,15 @@ fn api_env(config: &StackConfig) -> Vec<(&'static str, String)> {
("ODIAN_API_LOGDIR", text(logs.clone())),
("ODIAN_API_LOG", text(logs.join("odian_api.log"))),
("ODIAN_API_THREAD_STATE", text(logs.join("thread_state.txt"))),
]
];
// Live mode: with a DHP to forward to, the local API serves the DHP's data while it
// answers, and this PC's own the moment it stops. The token file may not exist yet
// (signed out, or offline) — the API simply stays local until it does.
if let Some(url) = &config.upstream_url {
env.push(("ODIAN_UPSTREAM_URL", url.clone()));
env.push(("ODIAN_UPSTREAM_TOKEN_FILE", text(upstream_token_file(&config.layout))));
}
env
}
fn tiles_env(config: &StackConfig) -> Vec<(&'static str, String)> {
@@ -504,6 +544,14 @@ pub fn installed_tile_sets(layout: &Layout) -> Result<Vec<(String, String)>, Str
#[cfg(test)]
mod tests {
#[test]
fn a_bound_port_reads_as_taken() {
let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).expect("bind");
let port = listener.local_addr().expect("addr").port();
assert!(super::port_taken(port), "a port someone is listening on must read as taken");
drop(listener);
}
use super::*;
fn config(tile_sets: &[(&str, &str)]) -> StackConfig {
@@ -514,6 +562,7 @@ mod tests {
pg_password: "pw".to_string(),
install_id: "install".to_string(),
tile_sets: tile_sets.iter().map(|(s, d)| (s.to_string(), d.to_string())).collect(),
upstream_url: None,
}
}
@@ -521,6 +570,19 @@ mod tests {
env.iter().find(|(k, _)| *k == key).map(|(_, v)| v.as_str())
}
#[test]
fn the_api_is_told_about_the_dhp_only_when_there_is_one() {
let alone = config(&[]);
assert_eq!(value(&api_env(&alone), "ODIAN_UPSTREAM_URL"), None, "no DHP: this PC serves its own data");
assert_eq!(value(&api_env(&alone), "ODIAN_UPSTREAM_TOKEN_FILE"), None);
let mut live = config(&[]);
live.upstream_url = Some("http://dhp.example:30684".to_string());
let env = api_env(&live);
assert_eq!(value(&env, "ODIAN_UPSTREAM_URL"), Some("http://dhp.example:30684"));
assert!(value(&env, "ODIAN_UPSTREAM_TOKEN_FILE").is_some_and(|path| path.ends_with("upstream.token")));
}
#[test]
fn layout_resolves_manifest_paths_inside_the_version() {
let layout = Layout::new(Path::new(r"D:\ODIAN"), "0.52.0");
+12
View File
@@ -30,6 +30,18 @@ pub struct LauncherState {
/// database holds no operator work yet and may be (re)created from a snapshot.
#[serde(default)]
pub db_snapshot: Option<String>,
/// Where that snapshot starts syncing (the manifest's `db.sync`). None for a snapshot
/// from before sync: its first sync then starts from the DHP's state at that moment.
#[serde(default)]
pub sync_origin: Option<SyncOrigin>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SyncOrigin {
/// The DHP database the snapshot came from.
pub db_id: String,
/// The first transaction the snapshot may be missing.
pub horizon: String,
}
fn state_dir(install_dir: &Path) -> PathBuf {
+292
View File
@@ -0,0 +1,292 @@
//! Operator-data sync with the DHP (M3), this PC's side.
//!
//! The merge rules live in SQL that ships inside each signed version
//! (`versions\<v>\sync\*.sql`, the same files the DHP runs). The launcher only
//! carries JSON between the two databases, while the local Engine API is stopped
//! so nothing it caches goes stale:
//!
//! sync.outbox → POST /v1/sync/push → sync.pushed
//! POST /v1/sync/pull (every page) → sync.apply_pull
//!
//! Conflicts wait in the local `sync.conflicts` table until the operator decides.
use std::path::Path;
use std::time::Duration;
use reqwest::StatusCode;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use crate::localdb::Pg;
/// Load order of the shipped SQL (the DHP loads the same list: odian_sync.schema.SQL_FILES).
pub const SQL_FILES: [&str; 5] = ["01_core.sql", "02_mark.sql", "03_dhp.sql", "04_install.sql", "05_registry.sql"];
/// Records per push; the DHP takes at most 2000.
const PUSH_BATCH: usize = 1000;
/// Changes per pull page; the DHP serves at most 2000.
const PULL_PAGE: u32 = 500;
const HTTP_TIMEOUT: Duration = Duration::from_secs(300);
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SyncStatus {
/// Local edits the DHP has not taken yet.
pub pending: u64,
/// Records both sides changed: the operator decides.
pub conflicts: u64,
/// Edits the DHP refused (another operator's record, ...).
pub rejected: u64,
/// DHP changes this PC could not apply yet.
pub waiting: u64,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Conflict {
pub tbl: String,
pub key: Value,
/// "conflict" or "rejected".
pub kind: String,
/// Clashing fields; "*exists" when one side deleted the record, "*value" for a whole-value clash.
pub fields: Vec<String>,
pub base: Option<Value>,
pub mine: Option<Value>,
pub theirs: Option<Value>,
pub reason: Option<String>,
}
#[derive(Debug)]
pub enum SyncError {
/// The DHP refused the Engine token: renew it and try again.
Unauthorized,
/// The DHP cannot sync right now (unreachable, sync not set up, an Engine that does not
/// name operators). Edits stay on this PC for the next connected run.
Unavailable(String),
Failed(String),
}
impl From<String> for SyncError {
fn from(message: String) -> Self {
SyncError::Failed(message)
}
}
impl std::fmt::Display for SyncError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SyncError::Unauthorized => write!(f, "the DHP refused this PC's sign-in"),
SyncError::Unavailable(message) | SyncError::Failed(message) => write!(f, "{message}"),
}
}
}
/// The DHP's update service and the token to talk to it with.
pub struct Dhp<'a> {
pub http: &'a reqwest::Client,
pub site_url: &'a str,
pub token: &'a str,
}
#[derive(Debug, Default)]
pub struct Report {
/// Records sent (conflicts waiting on the operator are sent again each run).
pub sent: usize,
/// Records the DHP took.
pub accepted: u64,
/// DHP changes received.
pub received: usize,
pub status: SyncStatus,
}
/// Does this version ship the sync schema?
pub fn schema_shipped(dir: &Path) -> bool {
SQL_FILES.iter().all(|name| dir.join(name).is_file())
}
/// `text` as an SQL string literal.
fn literal(text: &str) -> String {
format!("'{}'", text.replace('\'', "''"))
}
/// A JSON value as a jsonb literal, dollar-quoted with a tag its text cannot contain.
fn jsonb(value: &Value) -> String {
let text = value.to_string();
let tag = (0u32..).map(|n| format!("$j{n}$")).find(|tag| !text.contains(tag.as_str())).unwrap_or_default();
format!("{tag}{text}{tag}::jsonb")
}
/// One statement whose single result is JSON (NULL when it printed nothing).
fn query(pg: &Pg, sql: &str) -> Result<Value, String> {
let out = pg.script(&format!("{sql};\n"))?;
let text = out.trim();
if text.is_empty() {
return Ok(Value::Null);
}
serde_json::from_str(text).map_err(|e| format!("unexpected answer from this PC's database: {e}"))
}
async fn blocking<T: Send + 'static>(
pg: &Pg,
work: impl FnOnce(&Pg) -> Result<T, String> + Send + 'static,
) -> Result<T, String> {
let pg = pg.clone();
tauri::async_runtime::spawn_blocking(move || work(&pg)).await.map_err(|e| format!("sync worker failed: {e}"))?
}
/// Load (or upgrade) the version's sync schema in one transaction. Every file is
/// idempotent, so this runs before each sync and a new version's rules apply at once.
pub fn load_schema(pg: &Pg, dir: &Path) -> Result<(), String> {
let mut script = String::from("BEGIN;\n");
for name in SQL_FILES {
let path = dir.join(name);
script += &std::fs::read_to_string(&path).map_err(|e| format!("cannot read {}: {e}", path.display()))?;
script.push('\n');
}
script.push_str("COMMIT;\n");
pg.script(&script).map(|_| ())
}
/// The DHP database this one syncs with, or None before its first sync.
pub fn joined_db_id(pg: &Pg) -> Result<Option<String>, String> {
if query(pg, "SELECT to_jsonb(to_regclass('sync.state') IS NOT NULL)")? != Value::Bool(true) {
return Ok(None);
}
let id = query(pg, "SELECT to_jsonb((SELECT value FROM sync.state WHERE name = 'db_id'))")?;
Ok(id.as_str().map(str::to_string))
}
/// Make this database an install of the DHP database `db_id`, pulling from transaction `since`.
pub fn join(pg: &Pg, db_id: &str, since: &str) -> Result<(), String> {
query(pg, &format!("SELECT sync.install('install', {}, {})", literal(db_id), literal(since))).map(|_| ())
}
pub fn status(pg: &Pg) -> Result<SyncStatus, String> {
serde_json::from_value(query(pg, "SELECT sync.status()")?).map_err(|e| format!("unreadable sync status: {e}"))
}
/// What the operator has to decide (links held with a record are resolved with it).
pub fn conflicts(pg: &Pg) -> Result<Vec<Conflict>, String> {
let rows = query(
pg,
"SELECT coalesce(jsonb_agg(to_jsonb(c) - 'detected_at' - 'depends' ORDER BY c.detected_at), '[]') \
FROM sync.conflicts c WHERE c.depends IS NULL",
)?;
serde_json::from_value(rows).map_err(|e| format!("unreadable sync conflicts: {e}"))
}
/// Record the operator's decision: "theirs", "mine", "value" (with `value`) or "dismiss".
pub fn resolve(pg: &Pg, tbl: &str, key: &Value, choice: &str, value: Option<&Value>) -> Result<(), String> {
if !matches!(choice, "theirs" | "mine" | "value" | "dismiss") {
return Err(format!("{choice:?} is not a sync decision"));
}
let value = value.map(jsonb).unwrap_or_else(|| "NULL".to_string());
query(pg, &format!("SELECT sync.resolve({}, {}, {}, {value})", literal(tbl), jsonb(key), literal(choice))).map(|_| ())
}
async fn call(dhp: &Dhp<'_>, path: &str, body: Option<&Value>) -> Result<Value, SyncError> {
let url = format!("{}{path}", dhp.site_url);
let request = match body {
Some(body) => dhp.http.post(url).json(body),
None => dhp.http.get(url),
};
let response = request
.bearer_auth(dhp.token)
.timeout(HTTP_TIMEOUT)
.send()
.await
.map_err(|e| SyncError::Unavailable(format!("the DHP did not answer: {e}")))?;
let status = response.status();
if status.is_success() {
return response.json().await.map_err(|e| SyncError::Failed(format!("the DHP's answer was unreadable: {e}")));
}
if status == StatusCode::UNAUTHORIZED {
return Err(SyncError::Unauthorized);
}
let detail = response.json::<Value>().await.ok().and_then(|b| b.get("error").and_then(Value::as_str).map(str::to_string));
Err(match status {
StatusCode::NOT_FOUND => SyncError::Unavailable("this DHP does not sync operator data".to_string()),
StatusCode::SERVICE_UNAVAILABLE => {
SyncError::Unavailable(detail.unwrap_or_else(|| "the DHP cannot sync right now".to_string()))
}
other => SyncError::Failed(detail.unwrap_or_else(|| format!("the DHP answered HTTP {other}"))),
})
}
/// The DHP database's sync id and its current horizon.
pub async fn hello(dhp: &Dhp<'_>) -> Result<(String, String), SyncError> {
let answer = call(dhp, "/v1/sync/hello", None).await?;
match (answer.get("db_id").and_then(Value::as_str), answer.get("horizon").and_then(Value::as_str)) {
(Some(db_id), Some(horizon)) => Ok((db_id.to_string(), horizon.to_string())),
_ => Err(SyncError::Failed("the DHP's sync greeting is incomplete".to_string())),
}
}
/// Push this PC's edits; then, when `pull`, apply everything new on the DHP.
/// Postgres must be running and the local Engine API stopped.
pub async fn run(pg: &Pg, dhp: &Dhp<'_>, install_id: &str, pull: bool) -> Result<Report, SyncError> {
let mut report = Report::default();
loop {
let items = blocking(pg, |pg| query(pg, &format!("SELECT sync.outbox({PUSH_BATCH})"))).await?;
let count = items.as_array().map_or(0, Vec::len);
if count == 0 {
break;
}
let answer = call(dhp, "/v1/sync/push", Some(&json!({ "install_id": install_id, "changes": items }))).await?;
let recorded =
blocking(pg, move |pg| query(pg, &format!("SELECT sync.pushed({}, {})", jsonb(&items), jsonb(&answer)))).await?;
let accepted = recorded.get("accepted").and_then(Value::as_u64).unwrap_or(0);
report.sent += count;
report.accepted += accepted;
// Records waiting on the operator go out every time: stop once a batch moves nothing.
if count < PUSH_BATCH || accepted == 0 {
break;
}
}
if pull {
let since = blocking(pg, |pg| query(pg, "SELECT to_jsonb((SELECT value FROM sync.state WHERE name = 'since'))")).await?;
let since = since.as_str().ok_or_else(|| SyncError::Failed("this PC's sync cursor is missing".to_string()))?;
let mut cursor = json!({ "since": since });
let mut changes: Vec<Value> = Vec::new();
let db_id = loop {
let page = call(dhp, "/v1/sync/pull", Some(&json!({ "cursor": cursor, "limit": PULL_PAGE }))).await?;
if let Some(list) = page.get("changes").and_then(Value::as_array) {
changes.extend(list.iter().cloned());
}
cursor = page.get("next").cloned().unwrap_or(Value::Null);
if page.get("done").and_then(Value::as_bool).unwrap_or(true) {
break page.get("db_id").and_then(Value::as_str).unwrap_or_default().to_string();
}
};
let next = cursor.get("since").and_then(Value::as_str).unwrap_or_default().to_string();
if next.is_empty() {
return Err(SyncError::Failed("the DHP ended a pull without a cursor".to_string()));
}
report.received = changes.len();
let batch = Value::Array(changes);
blocking(pg, move |pg| {
query(pg, &format!("SELECT sync.apply_pull({}, {}, {})", jsonb(&batch), literal(&next), literal(&db_id)))
})
.await?;
}
report.status = blocking(pg, status).await?;
Ok(report)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn literals_double_single_quotes() {
assert_eq!(literal("it's"), "'it''s'");
}
#[test]
fn jsonb_literals_pick_a_tag_the_payload_does_not_contain() {
let plain = json!({ "name": "Alpha" });
assert_eq!(jsonb(&plain), "$j0${\"name\":\"Alpha\"}$j0$::jsonb");
let sneaky = json!({ "name": "$j0$; DROP TABLE x; --" });
let quoted = jsonb(&sneaky);
assert!(quoted.starts_with("$j1$") && quoted.ends_with("$j1$::jsonb"), "{quoted}");
}
}