From 3fdfde704379529a9e71684c9f939bd68427d58c Mon Sep 17 00:00:00 2001
From: Kelorastus <108483685+Kelorastus@users.noreply.github.com>
Date: Wed, 16 Sep 2026 11:42:24 +0200
Subject: [PATCH] M4
---
crates/odian-packs/src/manifest.rs | 33 +++
docs/CONTRACT.md | 45 ++++
src-tauri/src/commands.rs | 24 ++
src-tauri/src/launcher.rs | 358 ++++++++++++++++++++++++++++-
src-tauri/src/lib.rs | 4 +
src-tauri/src/localdb.rs | 55 ++++-
src-tauri/src/site.rs | 11 +-
src-tauri/src/stack.rs | 66 +++++-
src-tauri/src/state.rs | 12 +
src-tauri/src/sync.rs | 292 +++++++++++++++++++++++
src/App.svelte | 16 ++
src/components/ActionPanel.svelte | 35 +++
src/components/StatusBar.svelte | 37 +++
src/components/SyncPanel.svelte | 316 +++++++++++++++++++++++++
src/lib/backend.ts | 5 +
src/lib/launcher.svelte.ts | 38 +++
src/lib/mock.ts | 108 ++++++++-
src/lib/types.ts | 51 ++++
18 files changed, 1485 insertions(+), 21 deletions(-)
create mode 100644 src-tauri/src/sync.rs
create mode 100644 src/components/SyncPanel.svelte
diff --git a/crates/odian-packs/src/manifest.rs b/crates/odian-packs/src/manifest.rs
index 3a2f37f..f5ab27a 100644
--- a/crates/odian-packs/src/manifest.rs
+++ b/crates/odian-packs/src/manifest.rs
@@ -142,6 +142,20 @@ fn is_name(s: &str) -> bool {
(1..=128).contains(&s.len()) && s.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b'-'))
}
+/// Lower-case hyphenated uuid, as Postgres prints gen_random_uuid().
+fn is_uuid(s: &str) -> bool {
+ s.len() == 36
+ && s.bytes().enumerate().all(|(i, b)| match i {
+ 8 | 13 | 18 | 23 => b == b'-',
+ _ => b.is_ascii_digit() || (b'a'..=b'f').contains(&b),
+ })
+}
+
+/// A Postgres xid8 in decimal.
+fn is_xid(s: &str) -> bool {
+ (1..=20).contains(&s.len()) && s.bytes().all(|b| b.is_ascii_digit())
+}
+
fn is_snapshot(s: &str) -> bool {
let b = s.as_bytes();
b.len() == 16
@@ -216,6 +230,13 @@ pub fn validate(manifest: &Value) -> Result<()> {
.ok_or_else(|| Error::manifest("db.snapshot must look like 20260915T101500Z"))?;
let files = entries(db.get("files"), "db.files")?;
require(!files.is_empty(), || "db.files must not be empty".into())?;
+ if let Some(sync) = db.get("sync") {
+ let db_id = sync.get("db_id").and_then(Value::as_str).is_some_and(is_uuid);
+ let horizon = sync.get("horizon").and_then(Value::as_str).is_some_and(is_xid);
+ require(db_id && horizon, || {
+ "db.sync must hold the DHP's db_id (a uuid) and a horizon (a transaction id)".into()
+ })?;
+ }
let prefix = format!("db/{snapshot}/");
let mut seen: HashSet = HashSet::new();
for (i, entry) in files.iter().enumerate() {
@@ -328,6 +349,18 @@ pub struct DbSection {
pub schema_version: u64,
pub snapshot: String,
pub files: Vec,
+ /// Present when the snapshot comes from a DHP running operator-data sync.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub sync: Option,
+}
+
+/// Where an install restored from this snapshot starts syncing.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct DbSync {
+ /// The DHP database's sync id: an install only ever syncs with the database it came from.
+ pub db_id: String,
+ /// Oldest transaction still running when the snapshot was taken; the first pull starts there.
+ pub horizon: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md
index 5fad931..b5615c4 100644
--- a/docs/CONTRACT.md
+++ b/docs/CONTRACT.md
@@ -16,6 +16,7 @@ type Phase =
| "signing_in" // browser sign-in in progress
| "installing" // downloading + verifying a version
| "restoring" // loading the database snapshot into local Postgres
+ | "syncing" // exchanging operator data with the DHP (ODIAN is not running)
| "launching" // starting Postgres → API → tile server → Odian.exe
| "running" // Odian.exe is open
| "error"; // the last long operation failed; see `error`
@@ -34,6 +35,28 @@ interface Status {
phase: Phase;
error: string | null; // set with phase "error"; cleared by the next command or `refresh`
install_dir: string; // e.g. "D:\\ODIAN"
+ sync: SyncStatus | null; // counts from the last sync this session; null before one
+ live: boolean | null; // while ODIAN runs: true = its data comes from the DHP,
+ // false = from this PC. null when it is not running, or
+ // the DHP offers no Engine API to forward to
+}
+
+interface SyncStatus {
+ pending: number; // local edits the DHP has not taken yet
+ conflicts: number; // records both sides changed: the operator decides
+ rejected: number; // edits the DHP refused (another operator's record, ...)
+ waiting: number; // DHP changes this PC could not apply yet
+}
+
+interface SyncConflict {
+ tbl: string; // database table, e.g. "db_ground_poi"
+ key: Record; // the record's key columns
+ kind: "conflict" | "rejected";
+ fields: string[]; // clashing fields; "*exists": one side deleted it; "*value": a whole-value clash
+ base: unknown; // the DHP value before this PC's edit
+ mine: unknown; // this PC's value (null = deleted here)
+ theirs: unknown; // the DHP's value (null = deleted there)
+ reason: string | null; // why the DHP refused it (rejected only)
}
interface Settings {
@@ -83,12 +106,34 @@ interface LogEvent { level: "info" | "warn" | "error"; message: string; at: numb
| `get_settings` | — | `Settings` | |
| `set_settings` | `{ settings: Settings }` | `Status` | |
| `pick_folder` | — | `string \| null` | native folder picker |
+| `sync_now` | — | `void` | pushes and pulls operator data now; only while ODIAN is stopped (phase `syncing` meanwhile) |
+| `get_sync_conflicts` | — | `SyncConflict[]` | what waits for a decision, oldest first; starts the local Postgres just for the read |
+| `resolve_sync_conflict` | `{ tbl, key, choice: "theirs" \| "mine" \| "value" \| "dismiss", value?: unknown }` | `SyncConflict[]` | records one decision and returns what is left. `theirs` takes the DHP's version; `mine` keeps this PC's (the next sync sends it); `value` keeps a field-by-field pick; `dismiss` keeps a refused edit on this PC only. Links and folder placements held with a record follow its decision |
Errors reject the promise with a string message.
Planned, not yet available: the size of `available` for the Install button, and
inspecting a drive (its version and size) before installing from it.
+## Live mode
+
+When the DHP advertises an Engine API (`engine_api_url` in its `/v1/health`), the
+launcher starts the local API pointed at it and keeps a token file fresh for it. ODIAN
+always talks to the local API; while the DHP answers, that API forwards the shared-data
+routes there, so operators see each other's work and tracks are live. The moment the DHP
+stops answering, the same API serves this PC's own database instead — no restart, and the
+request that discovers the outage is already served locally. Coming back, the launcher
+first sends what was edited offline, then hands the token back and ODIAN is live again.
+`status.live` says which of the two is happening.
+
+## Operator-data sync
+
+When the installed version ships the sync schema (`versions\\sync\*.sql`) and the
+DHP is reachable, `launch` first syncs (push, then pull), then starts the stack. When
+ODIAN closes, the launcher pushes that session's work. Offline, edits stay on this PC.
+A sync that cannot finish is logged and never blocks the launch. Conflicts and refused
+edits wait in the local database until `resolve_sync_conflict`.
+
## Events (`listen`)
| Event | Payload | When |
diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs
index deed6dd..9f0b6b3 100644
--- a/src-tauri/src/commands.rs
+++ b/src-tauri/src/commands.rs
@@ -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 = Result;
@@ -89,6 +90,29 @@ pub async fn get_stack(launcher: State<'_, Launcher>) -> CmdResult) -> CmdResult<()> {
+ launcher.sync_now(&app).await
+}
+
+#[tauri::command]
+pub async fn get_sync_conflicts(app: AppHandle, launcher: State<'_, Launcher>) -> CmdResult> {
+ 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,
+) -> CmdResult> {
+ launcher.resolve_sync_conflict(&app, tbl, key, choice, value).await
+}
+
#[tauri::command]
pub async fn get_settings(launcher: State<'_, Launcher>) -> CmdResult {
Ok(launcher.settings().await)
diff --git a/src-tauri/src/launcher.rs b/src-tauri/src/launcher.rs
index 8bf9e78..a3b6352 100644
--- a/src-tauri/src/launcher.rs
+++ b/src-tauri/src/launcher.rs
@@ -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,
pub install_dir: String,
+ /// Operator-data sync counts from the last sync this session; None before one.
+ pub sync: Option,
+ /// 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,
}
#[derive(Clone, Serialize)]
@@ -90,6 +99,11 @@ struct Inner {
token: Option,
phase: Phase,
error: Option,
+ sync: Option,
+ /// 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,
+ live: Option,
}
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::().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 {
+ 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,
+ pull: bool,
+ ) -> Result {
+ 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::()
+ .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(
+ &self,
+ app: &AppHandle,
+ work: impl FnOnce(&Pg) -> Result + Send + 'static,
+ ) -> Result {
+ 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, 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,
+ ) -> Result, 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() {
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 3a83160..922cdfd 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -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")
diff --git a/src-tauri/src/localdb.rs b/src-tauri/src/localdb.rs
index 8b92712..3292c2a 100644
--- a/src-tauri/src/localdb.rs
+++ b/src-tauri/src/localdb.rs
@@ -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 {
+ 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::>().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 {
+ /// 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 {
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)
}
diff --git a/src-tauri/src/site.rs b/src-tauri/src/site.rs
index 569719e..581f788 100644
--- a/src-tauri/src/site.rs
+++ b/src-tauri/src/site.rs
@@ -11,10 +11,13 @@ pub struct Probe {
pub online: bool,
/// The site's CURRENT version.
pub version: Option,
+ /// 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,
}
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 {
diff --git a/src-tauri/src/stack.rs b/src-tauri/src/stack.rs
index a3a5b94..3a15ca0 100644
--- a/src-tauri/src/stack.rs
+++ b/src-tauri/src/stack.rs
@@ -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,
+}
+
+/// 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 {
+ 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, 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");
diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs
index e71a74e..9321c1d 100644
--- a/src-tauri/src/state.rs
+++ b/src-tauri/src/state.rs
@@ -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,
+ /// 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,
+}
+
+#[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 {
diff --git a/src-tauri/src/sync.rs b/src-tauri/src/sync.rs
new file mode 100644
index 0000000..6938e3f
--- /dev/null
+++ b/src-tauri/src/sync.rs
@@ -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\\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,
+ pub base: Option,
+ pub mine: Option,
+ pub theirs: Option,
+ pub reason: Option,
+}
+
+#[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 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 {
+ 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(
+ pg: &Pg,
+ work: impl FnOnce(&Pg) -> Result + Send + 'static,
+) -> Result {
+ 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