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
+33
View File
@@ -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'-')) (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 { fn is_snapshot(s: &str) -> bool {
let b = s.as_bytes(); let b = s.as_bytes();
b.len() == 16 b.len() == 16
@@ -216,6 +230,13 @@ pub fn validate(manifest: &Value) -> Result<()> {
.ok_or_else(|| Error::manifest("db.snapshot must look like 20260915T101500Z"))?; .ok_or_else(|| Error::manifest("db.snapshot must look like 20260915T101500Z"))?;
let files = entries(db.get("files"), "db.files")?; let files = entries(db.get("files"), "db.files")?;
require(!files.is_empty(), || "db.files must not be empty".into())?; 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 prefix = format!("db/{snapshot}/");
let mut seen: HashSet<String> = HashSet::new(); let mut seen: HashSet<String> = HashSet::new();
for (i, entry) in files.iter().enumerate() { for (i, entry) in files.iter().enumerate() {
@@ -328,6 +349,18 @@ pub struct DbSection {
pub schema_version: u64, pub schema_version: u64,
pub snapshot: String, pub snapshot: String,
pub files: Vec<DbFile>, pub files: Vec<DbFile>,
/// Present when the snapshot comes from a DHP running operator-data sync.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sync: Option<DbSync>,
}
/// 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)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+45
View File
@@ -16,6 +16,7 @@ type Phase =
| "signing_in" // browser sign-in in progress | "signing_in" // browser sign-in in progress
| "installing" // downloading + verifying a version | "installing" // downloading + verifying a version
| "restoring" // loading the database snapshot into local Postgres | "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 | "launching" // starting Postgres → API → tile server → Odian.exe
| "running" // Odian.exe is open | "running" // Odian.exe is open
| "error"; // the last long operation failed; see `error` | "error"; // the last long operation failed; see `error`
@@ -34,6 +35,28 @@ interface Status {
phase: Phase; phase: Phase;
error: string | null; // set with phase "error"; cleared by the next command or `refresh` error: string | null; // set with phase "error"; cleared by the next command or `refresh`
install_dir: string; // e.g. "D:\\ODIAN" 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<string, unknown>; // 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 { interface Settings {
@@ -83,12 +106,34 @@ interface LogEvent { level: "info" | "warn" | "error"; message: string; at: numb
| `get_settings` | — | `Settings` | | | `get_settings` | — | `Settings` | |
| `set_settings` | `{ settings: Settings }` | `Status` | | | `set_settings` | `{ settings: Settings }` | `Status` | |
| `pick_folder` | — | `string \| null` | native folder picker | | `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. Errors reject the promise with a string message.
Planned, not yet available: the size of `available` for the Install button, and Planned, not yet available: the size of `available` for the Install button, and
inspecting a drive (its version and size) before installing from it. 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\<v>\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`) ## Events (`listen`)
| Event | Payload | When | | Event | Payload | When |
+24
View File
@@ -7,6 +7,7 @@ use tauri_plugin_opener::OpenerExt;
use crate::launcher::{Launcher, Status}; use crate::launcher::{Launcher, Status};
use crate::settings::Settings; use crate::settings::Settings;
use crate::stack::StackEvent; use crate::stack::StackEvent;
use crate::sync::Conflict;
type CmdResult<T> = Result<T, String>; 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()) 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] #[tauri::command]
pub async fn get_settings(launcher: State<'_, Launcher>) -> CmdResult<Settings> { pub async fn get_settings(launcher: State<'_, Launcher>) -> CmdResult<Settings> {
Ok(launcher.settings().await) Ok(launcher.settings().await)
+356 -2
View File
@@ -1,6 +1,7 @@
//! The launcher's live state behind every command, and the `status` event. //! The launcher's live state behind every command, and the `status` event.
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc; use std::sync::Arc;
@@ -15,7 +16,8 @@ use crate::localdb::{self, Pg};
use crate::settings::{normalize_url, Settings}; use crate::settings::{normalize_url, Settings};
use crate::site::{self, SessionRefresh, TokenCheck}; use crate::site::{self, SessionRefresh, TokenCheck};
use crate::stack::{self, Layout, RunningStack, StackConfig, StackEvent}; 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}; use crate::tokens::{self, SiteToken};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
@@ -26,6 +28,8 @@ pub enum Phase {
SigningIn, SigningIn,
Installing, Installing,
Restoring, Restoring,
/// Exchanging operator data with the DHP (ODIAN is not running meanwhile).
Syncing,
Launching, Launching,
Running, Running,
Error, Error,
@@ -50,6 +54,11 @@ pub struct Status {
pub phase: Phase, pub phase: Phase,
pub error: Option<String>, pub error: Option<String>,
pub install_dir: 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)] #[derive(Clone, Serialize)]
@@ -90,6 +99,11 @@ struct Inner {
token: Option<SiteToken>, token: Option<SiteToken>,
phase: Phase, phase: Phase,
error: Option<String>, 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 { impl Inner {
@@ -111,6 +125,8 @@ impl Inner {
phase: self.phase, phase: self.phase,
error: self.error.clone(), error: self.error.clone(),
install_dir: self.settings.install_dir.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}"))?; .map_err(|e| format!("cannot start the HTTP client: {e}"))?;
Ok(Launcher { Ok(Launcher {
http, 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: Mutex::new(None),
stack_states: std::sync::Mutex::new(Vec::new()), stack_states: std::sync::Mutex::new(Vec::new()),
sign_in_cancel: Notify::new(), sign_in_cancel: Notify::new(),
@@ -253,6 +269,10 @@ impl Launcher {
let mut inner = self.inner.lock().await; let mut inner = self.inner.lock().await;
inner.online = probe.online; inner.online = probe.online;
inner.available = probe.version; 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. // Offline nothing is checked or forgotten: the remembered operator still launches.
if let (true, Some(token)) = (probe.online, token) { if let (true, Some(token)) = (probe.online, token) {
@@ -604,6 +624,8 @@ impl Launcher {
restored.map_err(|e| format!("The database restore failed: {e}"))?; restored.map_err(|e| format!("The database restore failed: {e}"))?;
let mut inner = self.inner.lock().await; let mut inner = self.inner.lock().await;
inner.state.db_snapshot = Some(db.snapshot.clone()); 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)?; inner.state.save(install_dir)?;
} }
@@ -682,6 +704,7 @@ impl Launcher {
pg_password: inner.state.pg_password.clone(), pg_password: inner.state.pg_password.clone(),
install_id: inner.state.install_id.clone(), install_id: inner.state.install_id.clone(),
tile_sets, tile_sets,
upstream_url: inner.engine_url.clone(),
} }
}; };
if let Ok(mut states) = self.stack_states.lock() { if let Ok(mut states) = self.stack_states.lock() {
@@ -697,6 +720,19 @@ impl Launcher {
} }
async fn run_stack(&self, app: &AppHandle, config: StackConfig) { 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 emit = |event: StackEvent| self.record_stack(app, event);
let running = match stack::start(&self.http, &config, &emit).await { let running = match stack::start(&self.http, &config, &emit).await {
Ok(running) => running, Ok(running) => running,
@@ -709,14 +745,31 @@ impl Launcher {
*self.stack.lock().await = Some(running); *self.stack.lock().await = Some(running);
self.set_phase(app, Phase::Running).await; 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 { if let Some(odian) = odian {
stack::wait_exit(&odian).await; stack::wait_exit(&odian).await;
} }
// Odian.exe closed. If Stop already took the stack, it stopped the rest too. // 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(); let taken = self.stack.lock().await.take();
if let Some(mut running) = taken { if let Some(mut running) = taken {
running.stop(&emit).await; 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; self.finish(app, None).await;
} }
@@ -729,6 +782,307 @@ impl Launcher {
Ok(()) 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. /// The launcher is exiting: stop the stack cleanly rather than leave Postgres to crash recovery.
pub async fn shutdown(&self) { pub async fn shutdown(&self) {
if let Ok(slot) = self.install_cancel.lock() { if let Ok(slot) = self.install_cancel.lock() {
+4
View File
@@ -10,6 +10,7 @@ mod settings;
mod site; mod site;
mod stack; mod stack;
mod state; mod state;
mod sync;
mod tokens; mod tokens;
use tauri::Manager; use tauri::Manager;
@@ -53,6 +54,9 @@ pub fn run() {
commands::get_settings, commands::get_settings,
commands::set_settings, commands::set_settings,
commands::pick_folder, commands::pick_folder,
commands::sync_now,
commands::get_sync_conflicts,
commands::resolve_sync_conflict,
]) ])
.build(tauri::generate_context!()) .build(tauri::generate_context!())
.expect("error while building the launcher") .expect("error while building the launcher")
+45 -10
View File
@@ -107,6 +107,11 @@ impl Pg {
if self.running() { if self.running() {
return Ok(()); 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"); let mut cmd = self.tool("pg_ctl");
cmd cmd
.arg("start") .arg("start")
@@ -151,6 +156,34 @@ impl Pg {
Self::run(cmd, "psql") 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 /// 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. /// completed: anything an operator did in the old database is dropped.
pub fn restore( 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(["-h", "127.0.0.1", "-p", port.as_str(), "-U", PG_USER, "-d", PG_DATABASE])
.args(["--no-owner", "--no-privileges", "--exit-on-error"]); .args(["--no-owner", "--no-privileges", "--exit-on-error"]);
if pre_data { 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); cmd.arg(&dump);
Self::run(cmd, &format!("pg_restore {}", file.path)).map(|_| ()) Self::run(cmd, &format!("pg_restore {}", file.path)).map(|_| ())
} }
/// The pre-data table of contents without `CREATE SCHEMA public`: a fresh database /// The dump's table of contents without the entries `drop` names, for `pg_restore -L`.
/// already has that schema, and --exit-on-error would stop on the duplicate. fn toc_without(&self, dump: &Path, list_name: &str, drop: &dyn Fn(&str) -> bool) -> Result<PathBuf, String> {
fn pre_data_list(&self, dump: &Path) -> Result<PathBuf, String> {
let mut cmd = self.tool("pg_restore"); let mut cmd = self.tool("pg_restore");
cmd.arg("-l").arg(dump); cmd.arg("-l").arg(dump);
let listing = Self::run(cmd, "pg_restore -l")?; let listing = Self::run(cmd, "pg_restore -l")?;
let kept: String = listing let kept: String = listing.lines().filter(|line| !drop(line)).map(|line| format!("{line}\n")).collect();
.lines() let path = self.logs.join(list_name);
.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");
std::fs::write(&path, kept).map_err(|e| format!("cannot write {}: {e}", path.display()))?; std::fs::write(&path, kept).map_err(|e| format!("cannot write {}: {e}", path.display()))?;
Ok(path) Ok(path)
} }
+6 -5
View File
@@ -11,10 +11,13 @@ pub struct Probe {
pub online: bool, pub online: bool,
/// The site's CURRENT version. /// The site's CURRENT version.
pub version: Option<String>, 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 { 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() { if site_url.is_empty() {
return offline; return offline;
} }
@@ -25,10 +28,8 @@ pub async fn probe(http: &reqwest::Client, site_url: &str) -> Probe {
return offline; return offline;
} }
let body: serde_json::Value = response.json().await.unwrap_or_default(); let body: serde_json::Value = response.json().await.unwrap_or_default();
Probe { let text = |key: &str| body.get(key).and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(str::to_string);
online: true, Probe { online: true, version: text("version"), engine_url: text("engine_api_url") }
version: body.get("version").and_then(|v| v.as_str()).map(str::to_string),
}
} }
pub enum TokenCheck { pub enum TokenCheck {
+64 -2
View File
@@ -115,6 +115,10 @@ impl Layout {
pub fn pg_data(&self) -> PathBuf { pub fn pg_data(&self) -> PathBuf {
self.data_dir().join("pg") 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 { fn python(&self) -> PathBuf {
self.app_file(PYTHON_EXE) self.app_file(PYTHON_EXE)
} }
@@ -140,6 +144,15 @@ pub struct StackConfig {
pub install_id: String, pub install_id: String,
/// (style, dataset) of every tile set the installed version carries. /// (style, dataset) of every tile set the installed version carries.
pub tile_sets: Vec<(String, String)>, 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. /// 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> { pub async fn start(http: &reqwest::Client, config: &StackConfig, emit: Emit<'_>) -> Result<RunningStack, String> {
check_ports_free()?;
prepare(config)?; prepare(config)?;
let mut running = RunningStack { let mut running = RunningStack {
job: job::Job::new()?, job: job::Job::new()?,
@@ -413,7 +445,7 @@ fn datasets(config: &StackConfig) -> serde_json::Value {
fn api_env(config: &StackConfig) -> Vec<(&'static str, String)> { fn api_env(config: &StackConfig) -> Vec<(&'static str, String)> {
let api = config.layout.state_dir().join("api"); let api = config.layout.state_dir().join("api");
let logs = api.join("logs"); let logs = api.join("logs");
vec![ let mut env = vec![
("ODIAN_API_BIND", "127.0.0.1".to_string()), ("ODIAN_API_BIND", "127.0.0.1".to_string()),
("ODIAN_API_PORT", API_PORT.to_string()), ("ODIAN_API_PORT", API_PORT.to_string()),
("ODIAN_PG_INI", text(api.join("pg.ini"))), ("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_LOGDIR", text(logs.clone())),
("ODIAN_API_LOG", text(logs.join("odian_api.log"))), ("ODIAN_API_LOG", text(logs.join("odian_api.log"))),
("ODIAN_API_THREAD_STATE", text(logs.join("thread_state.txt"))), ("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)> { 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)] #[cfg(test)]
mod tests { 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::*; use super::*;
fn config(tile_sets: &[(&str, &str)]) -> StackConfig { fn config(tile_sets: &[(&str, &str)]) -> StackConfig {
@@ -514,6 +562,7 @@ mod tests {
pg_password: "pw".to_string(), pg_password: "pw".to_string(),
install_id: "install".to_string(), install_id: "install".to_string(),
tile_sets: tile_sets.iter().map(|(s, d)| (s.to_string(), d.to_string())).collect(), 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()) 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] #[test]
fn layout_resolves_manifest_paths_inside_the_version() { fn layout_resolves_manifest_paths_inside_the_version() {
let layout = Layout::new(Path::new(r"D:\ODIAN"), "0.52.0"); 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. /// database holds no operator work yet and may be (re)created from a snapshot.
#[serde(default)] #[serde(default)]
pub db_snapshot: Option<String>, 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 { 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}");
}
}
+16
View File
@@ -6,6 +6,7 @@
import KeyArt from "./components/KeyArt.svelte"; import KeyArt from "./components/KeyArt.svelte";
import NewsPanel from "./components/NewsPanel.svelte"; import NewsPanel from "./components/NewsPanel.svelte";
import SettingsDrawer from "./components/SettingsDrawer.svelte"; import SettingsDrawer from "./components/SettingsDrawer.svelte";
import SyncPanel from "./components/SyncPanel.svelte";
import StatusBar from "./components/StatusBar.svelte"; import StatusBar from "./components/StatusBar.svelte";
import TitleBar from "./components/TitleBar.svelte"; import TitleBar from "./components/TitleBar.svelte";
import { launcher } from "./lib/launcher.svelte"; import { launcher } from "./lib/launcher.svelte";
@@ -31,6 +32,10 @@
<div class="activity-slot"><ActivityPanel /></div> <div class="activity-slot"><ActivityPanel /></div>
{/if} {/if}
{#if launcher.syncOpen}
<div class="sync-slot"><SyncPanel /></div>
{/if}
<SettingsDrawer bind:open={settingsOpen} /> <SettingsDrawer bind:open={settingsOpen} />
</div> </div>
@@ -83,6 +88,14 @@
width: min(640px, calc(100% - 64px)); width: min(640px, calc(100% - 64px));
} }
.sync-slot {
position: absolute;
z-index: 31;
right: 32px;
bottom: calc(var(--statusbar-h) + 10px);
width: min(560px, calc(100% - 64px));
}
@media (max-width: 1140px) { @media (max-width: 1140px) {
.hub { .hub {
grid-template-columns: minmax(300px, 360px) minmax(0, 1fr) minmax(340px, 380px); grid-template-columns: minmax(300px, 360px) minmax(0, 1fr) minmax(340px, 380px);
@@ -92,6 +105,9 @@
.activity-slot { .activity-slot {
left: 24px; left: 24px;
} }
.sync-slot {
right: 24px;
}
} }
@media (max-height: 700px) { @media (max-height: 700px) {
+35
View File
@@ -60,6 +60,8 @@
return { label: s?.installed ? "Updating" : "Installing", tone: "accent", pulse: true, meta: stage }; return { label: s?.installed ? "Updating" : "Installing", tone: "accent", pulse: true, meta: stage };
case "restoring": case "restoring":
return { label: "Restoring database", tone: "accent", pulse: true, meta: stage }; return { label: "Restoring database", tone: "accent", pulse: true, meta: stage };
case "syncing":
return { label: "Syncing with the DHP", tone: "accent", pulse: true, meta: version(s?.installed) };
case "update": case "update":
return { label: "Update required", tone: "warn", pulse: false, meta: `${s?.installed} → ${s?.available}` }; return { label: "Update required", tone: "warn", pulse: false, meta: `${s?.installed} → ${s?.available}` };
case "launch": case "launch":
@@ -165,6 +167,21 @@
Offline: ODIAN <strong>{s?.installed}</strong> runs on local data and map tiles. Offline: ODIAN <strong>{s?.installed}</strong> runs on local data and map tiles.
{/if} {/if}
</p> </p>
{#if launcher.syncAttention > 0}
<p class="secondary attention">
<Icon name="alert" size={14} />
<span>
<strong>{launcher.syncAttention}</strong>
{launcher.syncAttention === 1 ? "change needs" : "changes need"} your decision.
<button class="btn-link" disabled={blocked} onclick={() => launcher.openSync()}>Review</button>
</span>
</p>
{:else if s?.online || s?.sync}
<p class="secondary small">
Operator data syncs with {site} before every launch.
<button class="btn-link" disabled={blocked} onclick={() => launcher.openSync()}>Sync</button>
</p>
{/if}
{:else if action === "installing"} {:else if action === "installing"}
<div class="progress"> <div class="progress">
<div class="stage-line"> <div class="stage-line">
@@ -211,6 +228,14 @@
{#if p?.stage === "restore" && p.message}<p class="message mono" title={p.message}>{p.message}</p>{/if} {#if p?.stage === "restore" && p.message}<p class="message mono" title={p.message}>{p.message}</p>{/if}
</div> </div>
<p class="secondary">This can take a few minutes. Keep the launcher open until it finishes.</p> <p class="secondary">This can take a few minutes. Keep the launcher open until it finishes.</p>
{:else if action === "syncing"}
<div class="progress">
<div class="stage-line">
<span class="stage-name">Exchanging operator data</span>
</div>
<ProgressBar value={null} label="Syncing with the DHP" />
</div>
<p class="secondary">Sending this PC's changes to {site} and taking in the other operators' work.</p>
{:else if action === "stack"} {:else if action === "stack"}
<StackList stack={launcher.stack} phase={s?.phase ?? "launching"} /> <StackList stack={launcher.stack} phase={s?.phase ?? "launching"} />
<button class="btn btn-danger wide" disabled={launcher.stopping || busy !== null} onclick={() => launcher.stop()}> <button class="btn btn-danger wide" disabled={launcher.stopping || busy !== null} onclick={() => launcher.stop()}>
@@ -371,6 +396,16 @@
.secondary.small { .secondary.small {
font-size: var(--text-xs); font-size: var(--text-xs);
} }
.attention {
display: flex;
align-items: flex-start;
gap: 8px;
color: var(--ink-2);
}
.attention :global(.icon) {
margin-top: 3px;
color: var(--warn);
}
.secondary strong { .secondary strong {
color: var(--ink-2); color: var(--ink-2);
font-weight: 500; font-weight: 500;
+37
View File
@@ -39,6 +39,21 @@
<span>Installed</span> <span>Installed</span>
<strong class="mono">{s.installed ?? "none"}</strong> <strong class="mono">{s.installed ?? "none"}</strong>
</span> </span>
{#if s.live !== null}
<span class="item" title={s.live ? `ODIAN is reading and writing ${launcher.siteName}'s data` : "ODIAN is working from this PC's data"}>
<Icon name={s.live ? "globe" : "folder"} size={14} />
<span>{s.live ? `Live on ${launcher.siteName}` : "This PC's data"}</span>
</span>
{/if}
{#if s.sync}
<button class="item sync-item" aria-controls="sync-panel" onclick={() => launcher.openSync()}>
<Icon name="refresh" size={14} />
<span><strong class="mono">{s.sync.pending}</strong> to send</span>
{#if s.sync.conflicts + s.sync.rejected > 0}
<strong class="review mono">{s.sync.conflicts + s.sync.rejected} to review</strong>
{/if}
</button>
{/if}
<span class="item dir" title={s.install_dir}> <span class="item dir" title={s.install_dir}>
<Icon name="folder" size={14} /> <Icon name="folder" size={14} />
<span class="mono selectable path">{s.install_dir}</span> <span class="mono selectable path">{s.install_dir}</span>
@@ -122,6 +137,28 @@
font-weight: 500; font-weight: 500;
} }
.sync-item {
border-top: 0;
border-bottom: 0;
border-left: 0;
background: transparent;
color: inherit;
font: inherit;
transition:
background-color var(--dur-fast) var(--ease),
color var(--dur-fast) var(--ease);
}
.sync-item:hover {
background: var(--surface-2);
color: var(--ink);
}
.sync-item:focus-visible {
outline-offset: -2px;
}
.item strong.review {
color: var(--warn);
}
.dir { .dir {
flex-shrink: 1; flex-shrink: 1;
} }
+316
View File
@@ -0,0 +1,316 @@
<script lang="ts">
import { fly } from "svelte/transition";
import { prefersReducedMotion } from "svelte/motion";
import Icon from "./Icon.svelte";
import { launcher } from "../lib/launcher.svelte";
import type { ConflictChoice, SyncConflict } from "../lib/types";
const KIND: Record<string, string> = {
db_ground_poi: "POI",
db_ground_target: "Target",
db_ground_area: "Area",
db_ground_line: "Line",
odb_note: "Note",
odb_folder: "Folder",
odb_images: "Image",
odb_sitac_operation: "Operation",
odb_sitac_operation_snapshot: "Operation state",
odb_target_list: "Target list",
odb_user_prefs: "Preferences",
odb_user_layouts: "Layouts",
};
const s = $derived(launcher.status);
const disabled = $derived(launcher.busy !== null || launcher.locked);
type Row = Record<string, unknown>;
const asRow = (value: unknown): Row | null =>
value !== null && typeof value === "object" && !Array.isArray(value) ? (value as Row) : null;
function kind(c: SyncConflict): string {
return KIND[c.tbl] ?? c.tbl.replace(/^o?db_/, "").replaceAll("_", " ");
}
function title(c: SyncConflict): string {
for (const side of [c.mine, c.theirs, c.base]) {
const row = asRow(side);
const name = row?.name ?? row?.title;
if (typeof name === "string" && name) return name;
}
return Object.values(c.key).join(" · ");
}
function shown(value: unknown): string {
if (value === undefined || value === null) return "—";
return typeof value === "string" ? value : JSON.stringify(value);
}
function rows(c: SyncConflict) {
return c.fields
.filter((field) => !field.startsWith("*"))
.map((field) => ({ field, mine: shown(asRow(c.mine)?.[field]), theirs: shown(asRow(c.theirs)?.[field]) }));
}
function explain(c: SyncConflict): string | null {
if (c.kind === "rejected") return `The DHP refused this change: ${c.reason ?? "no reason given"}.`;
if (c.fields.includes("*exists")) {
return c.mine === null
? "You deleted this on this PC; someone changed it on the DHP."
: "You changed this on this PC; someone deleted it on the DHP.";
}
if (c.fields.includes("*value")) return "It changed differently on this PC and on the DHP.";
return "The same fields changed on this PC and on the DHP.";
}
function choices(c: SyncConflict): [ConflictChoice, string][] {
if (c.kind === "rejected") {
return [
["theirs", "Undo my change"],
["dismiss", "Keep it on this PC only"],
];
}
if (c.fields.includes("*exists")) {
return c.mine === null
? [
["mine", "Delete it everywhere"],
["theirs", "Keep the DHP's version"],
]
: [
["mine", "Keep my version"],
["theirs", "Accept the delete"],
];
}
return [
["mine", "Keep this PC's"],
["theirs", "Take the DHP's"],
];
}
function onWindowKeydown(event: KeyboardEvent) {
if (event.key === "Escape" && !event.defaultPrevented && !document.querySelector("dialog[open]")) launcher.closeSync();
}
</script>
<svelte:window onkeydown={onWindowKeydown} />
<section
id="sync-panel"
class="sync glass"
aria-label="Sync review"
transition:fly={{ y: 8, duration: prefersReducedMotion.current ? 0 : 200 }}
>
<header class="head">
<h2 class="eyebrow">Sync</h2>
{#if launcher.conflicts?.length}<span class="total mono">{launcher.conflicts.length}</span>{/if}
<span class="grow"></span>
<button class="btn small" disabled={disabled || !s?.online} onclick={() => launcher.syncNow()}>
{launcher.busy === "sync" ? "Syncing…" : "Sync now"}
</button>
<button class="icon-btn" aria-label="Close sync review" onclick={() => launcher.closeSync()}>
<Icon name="close" size={14} />
</button>
</header>
{#if s?.sync}
<p class="summary">
<span><strong class="mono">{s.sync.pending}</strong> waiting to send</span>
{#if s.sync.waiting > 0}<span><strong class="mono">{s.sync.waiting}</strong> DHP changes not applied yet</span>{/if}
</p>
{/if}
{#if launcher.locked}
<p class="empty">Decisions can be made once ODIAN has stopped.</p>
{:else if launcher.conflicts === null}
<p class="empty">{launcher.busy === "conflicts" ? "Reading this PC's database…" : "Nothing loaded yet."}</p>
{:else if launcher.conflicts.length === 0}
<p class="empty">Nothing needs your decision. Operator data syncs before every launch and after ODIAN closes.</p>
{:else}
<ol class="list" aria-label="Changes that need a decision">
{#each launcher.conflicts as c (c.tbl + JSON.stringify(c.key))}
<li class="item">
<div class="item-head">
<span class="kind mono">{kind(c)}</span>
<span class="name selectable" title={title(c)}>{title(c)}</span>
<span class="tag" class:refused={c.kind === "rejected"}>{c.kind === "rejected" ? "Refused" : "Conflict"}</span>
</div>
<p class="why">{explain(c)}</p>
{#if rows(c).length}
<div class="diff-wrap">
<table class="diff">
<thead>
<tr><th scope="col">Field</th><th scope="col">This PC</th><th scope="col">DHP</th></tr>
</thead>
<tbody>
{#each rows(c) as row (row.field)}
<tr>
<th scope="row" class="mono">{row.field}</th>
<td class="selectable">{row.mine}</td>
<td class="selectable">{row.theirs}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
<div class="actions">
{#each choices(c) as [choice, label] (choice)}
<button class="btn small" {disabled} onclick={() => launcher.resolveConflict(c, choice)}>{label}</button>
{/each}
</div>
</li>
{/each}
</ol>
{/if}
</section>
<style>
.sync {
display: flex;
flex-direction: column;
max-height: min(520px, calc(100vh - 180px));
background: var(--glass-strong);
box-shadow: 0 24px 60px -24px rgba(0, 0, 0, 0.8);
}
.head {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 8px 8px 16px;
border-bottom: 1px solid var(--line);
}
.total {
color: var(--muted);
font-size: var(--text-xs);
}
.grow {
flex: 1;
}
.small {
height: 28px;
padding: 0 10px;
font-size: var(--text-xs);
}
.summary {
display: flex;
flex-wrap: wrap;
gap: 4px 16px;
padding: 8px 16px;
border-bottom: 1px solid var(--line);
color: var(--muted);
font-size: var(--text-xs);
}
.summary strong {
color: var(--ink-2);
font-weight: 500;
}
.empty {
padding: 16px;
color: var(--muted);
font-size: var(--text-sm);
line-height: 1.5;
}
.list {
margin: 0;
padding: 0;
overflow-y: auto;
list-style: none;
}
.item {
display: grid;
gap: 8px;
padding: 12px 16px;
}
.item + .item {
border-top: 1px solid var(--line);
}
.item-head {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.kind {
color: var(--muted);
font-size: var(--text-xs);
letter-spacing: 0.06em;
text-transform: uppercase;
}
.name {
flex: 1;
min-width: 0;
overflow: hidden;
color: var(--ink);
font-size: var(--text-sm);
font-weight: 500;
white-space: nowrap;
text-overflow: ellipsis;
}
.tag {
padding: 1px 6px;
border-radius: var(--radius);
background: var(--accent-soft);
color: var(--accent-hover);
font-size: var(--text-xs);
}
.tag.refused {
background: var(--danger-soft);
color: var(--danger);
}
.why {
color: var(--ink-2);
font-size: var(--text-xs);
line-height: 1.5;
}
.diff-wrap {
overflow-x: auto;
border: 1px solid var(--line);
}
.diff {
width: 100%;
border-collapse: collapse;
font-size: var(--text-xs);
}
.diff th,
.diff td {
padding: 5px 8px;
text-align: left;
vertical-align: top;
overflow-wrap: anywhere;
}
.diff thead th {
background: var(--surface-2);
color: var(--muted);
font-weight: 500;
}
.diff tbody tr + tr {
border-top: 1px solid var(--line);
}
.diff tbody th {
color: var(--muted);
font-weight: 400;
}
.diff td {
color: var(--ink-2);
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
</style>
+5
View File
@@ -13,6 +13,7 @@ import type {
SignInResult, SignInResult,
StackEvent, StackEvent,
Status, Status,
SyncConflict,
Unlisten, Unlisten,
} from "./types"; } from "./types";
@@ -52,6 +53,10 @@ function createTauriBackend(): Backend {
getSettings: () => invoke<Settings>("get_settings"), getSettings: () => invoke<Settings>("get_settings"),
setSettings: (settings) => invoke<Status>("set_settings", { settings }), setSettings: (settings) => invoke<Status>("set_settings", { settings }),
pickFolder: () => invoke<string | null>("pick_folder"), pickFolder: () => invoke<string | null>("pick_folder"),
syncNow: () => invoke<void>("sync_now"),
getSyncConflicts: () => invoke<SyncConflict[]>("get_sync_conflicts"),
resolveSyncConflict: (tbl, key, choice, value) =>
invoke<SyncConflict[]>("resolve_sync_conflict", { tbl, key, choice, value: value === undefined ? null : value }),
onStatus: (handler) => subscribe<Status>("status", handler), onStatus: (handler) => subscribe<Status>("status", handler),
onInstallProgress: (handler) => subscribe<InstallProgress>("install-progress", handler), onInstallProgress: (handler) => subscribe<InstallProgress>("install-progress", handler),
+38
View File
@@ -4,6 +4,7 @@ import { backend } from "./backend";
import { errorText, hostOf } from "./format"; import { errorText, hostOf } from "./format";
import { SIGN_IN_CANCELLED } from "./types"; import { SIGN_IN_CANCELLED } from "./types";
import type { import type {
ConflictChoice,
InstallProgress, InstallProgress,
LogEvent, LogEvent,
LogLevel, LogLevel,
@@ -12,6 +13,7 @@ import type {
StackComponent, StackComponent,
StackEvent, StackEvent,
Status, Status,
SyncConflict,
Unlisten, Unlisten,
} from "./types"; } from "./types";
@@ -24,6 +26,7 @@ export type ActionKind =
| "signed_out_offline" | "signed_out_offline"
| "installing" | "installing"
| "restoring" | "restoring"
| "syncing"
| "update" | "update"
| "launch" | "launch"
| "stack" | "stack"
@@ -45,6 +48,8 @@ export function deriveAction(s: Status | null): ActionKind {
return "installing"; return "installing";
case "restoring": case "restoring":
return "restoring"; return "restoring";
case "syncing":
return "syncing";
case "launching": case "launching":
case "running": case "running":
return "stack"; return "stack";
@@ -75,6 +80,7 @@ const LOCKED_PHASES: ReadonlySet<Phase> = new Set([
"signing_in", "signing_in",
"installing", "installing",
"restoring", "restoring",
"syncing",
"launching", "launching",
"running", "running",
]); ]);
@@ -104,6 +110,10 @@ class Launcher {
busy = $state<string | null>(null); busy = $state<string | null>(null);
refreshing = $state(false); refreshing = $state(false);
stopping = $state(false); stopping = $state(false);
/** The sync review panel. */
syncOpen = $state(false);
/** Decisions waiting in this PC's database; null until the panel loads them. */
conflicts = $state<SyncConflict[] | null>(null);
notes = $state<NotesState>({ notes = $state<NotesState>({
key: null, key: null,
loading: false, loading: false,
@@ -117,6 +127,8 @@ class Launcher {
/** Settings can't change while something is installing or running. */ /** Settings can't change while something is installing or running. */
locked = $derived(this.status ? LOCKED_PHASES.has(this.status.phase) : false); locked = $derived(this.status ? LOCKED_PHASES.has(this.status.phase) : false);
siteName = $derived(this.status ? (this.status.site.name ?? hostOf(this.status.site.url)) : ""); siteName = $derived(this.status ? (this.status.site.name ?? hostOf(this.status.site.url)) : "");
/** Conflicts plus refused edits from the last sync: what needs the operator. */
syncAttention = $derived(this.status?.sync ? this.status.sync.conflicts + this.status.sync.rejected : 0);
notesVersion = $derived( notesVersion = $derived(
this.status ? (this.status.online ? (this.status.available ?? this.status.installed) : this.status.installed) : null, this.status ? (this.status.online ? (this.status.available ?? this.status.installed) : this.status.installed) : null,
); );
@@ -339,6 +351,32 @@ class Launcher {
}); });
} }
syncNow() {
return this.run("sync", async () => {
await backend.syncNow();
// The panel lists what the sync just found (or settled).
if (this.syncOpen) this.conflicts = await backend.getSyncConflicts();
});
}
/** Opens the review panel and loads what waits for a decision. */
async openSync() {
this.syncOpen = true;
await this.run("conflicts", async () => {
this.conflicts = await backend.getSyncConflicts();
});
}
closeSync() {
this.syncOpen = false;
}
resolveConflict(conflict: SyncConflict, choice: ConflictChoice, value?: unknown) {
return this.run("resolve", async () => {
this.conflicts = await backend.resolveSyncConflict(conflict.tbl, conflict.key, choice, value);
});
}
async pickFolder(): Promise<string | null> { async pickFolder(): Promise<string | null> {
try { try {
return await backend.pickFolder(); return await backend.pickFolder();
+106 -2
View File
@@ -2,7 +2,7 @@
// `npm run dev`. Same shapes as docs/CONTRACT.md, driven by timers. // `npm run dev`. Same shapes as docs/CONTRACT.md, driven by timers.
// //
// ?scenario=first-run | update-required | ready | offline-installed | // ?scenario=first-run | update-required | ready | offline-installed |
// offline-not-installed | installing | running | error // offline-not-installed | installing | running | sync-conflicts | error
// &fail=sign_in | install | restore | launch | notes make that flow fail // &fail=sign_in | install | restore | launch | notes make that flow fail
// &speed=4 run timers 4x faster // &speed=4 run timers 4x faster
// &notes=no-roadmap notes without a Roadmap // &notes=no-roadmap notes without a Roadmap
@@ -14,6 +14,7 @@ import {
SIGN_IN_CANCELLED, SIGN_IN_CANCELLED,
type Backend, type Backend,
type InstallProgress, type InstallProgress,
type ConflictChoice,
type InstallStage, type InstallStage,
type LogEvent, type LogEvent,
type LogLevel, type LogLevel,
@@ -24,6 +25,8 @@ import {
type StackComponent, type StackComponent,
type StackEvent, type StackEvent,
type Status, type Status,
type SyncConflict,
type SyncStatus,
type Unlisten, type Unlisten,
} from "./types"; } from "./types";
@@ -131,6 +134,42 @@ const STAGE_DONE: Record<InstallStage, string> = {
finalize: "Install finalized", finalize: "Install finalized",
}; };
const POI = "poi_0192f3a1c4e87b5d9a2e6f10b3c4d5e6";
const TARGET = "tar_0192f3a2d91c7e4fa6b80c1d2e3f4a5b";
const AREA = "are_0192f3a3e0b1c2d3e4f5a6b7c8d9e0f1";
const MOCK_CONFLICTS: SyncConflict[] = [
{
tbl: "db_ground_poi",
key: { poi_id: POI },
kind: "conflict",
fields: ["name", "color"],
base: { poi_id: POI, name: "Relay hill", color: "red", lat: 48.61, lon: 2.35 },
mine: { poi_id: POI, name: "Relay hill (north slope)", color: "orange", lat: 48.61, lon: 2.35 },
theirs: { poi_id: POI, name: "Relay hill, confirmed", color: "yellow", lat: 48.61, lon: 2.35 },
reason: null,
},
{
tbl: "db_ground_target",
key: { target_id: TARGET },
kind: "conflict",
fields: ["*exists"],
base: { target_id: TARGET, name: "Bridge 14", status_id: null },
mine: null,
theirs: { target_id: TARGET, name: "Bridge 14", status_id: "st_damaged" },
reason: null,
},
{
tbl: "db_ground_area",
key: { area_id: AREA },
kind: "rejected",
fields: [],
base: null,
mine: { area_id: AREA, name: "NFA Delta (wider)" },
theirs: { area_id: AREA, name: "NFA Delta" },
reason: "only the owner can change or delete this",
},
];
type Payloads = { type Payloads = {
status: Status; status: Status;
"install-progress": InstallProgress; "install-progress": InstallProgress;
@@ -173,6 +212,8 @@ function initialStatus(scenario: MockScenario): Status {
phase: "idle", phase: "idle",
error: null, error: null,
install_dir: DEFAULT_DIR, install_dir: DEFAULT_DIR,
sync: null,
live: null,
}; };
switch (scenario) { switch (scenario) {
case "first-run": case "first-run":
@@ -193,7 +234,15 @@ function initialStatus(scenario: MockScenario): Status {
Object.assign(s, { operator: OPERATOR, signed_in: true, phase: "installing" }); Object.assign(s, { operator: OPERATOR, signed_in: true, phase: "installing" });
break; break;
case "running": case "running":
Object.assign(s, { installed: CURRENT, operator: OPERATOR, signed_in: true, phase: "running" }); Object.assign(s, { installed: CURRENT, operator: OPERATOR, signed_in: true, phase: "running", live: true });
break;
case "sync-conflicts":
Object.assign(s, {
installed: CURRENT,
operator: OPERATOR,
signed_in: true,
sync: { pending: 1, conflicts: 2, rejected: 1, waiting: 0 },
});
break; break;
case "error": case "error":
Object.assign(s, { Object.assign(s, {
@@ -225,6 +274,8 @@ class MockBackend implements Backend {
private job = 0; private job = 0;
private booted = false; private booted = false;
private signInCancelled = false; private signInCancelled = false;
/** What waits for a decision in the mock "local database". */
private conflicts: SyncConflict[];
/** Latest event per component, as `get_stack` reports it. */ /** Latest event per component, as `get_stack` reports it. */
private readonly stackStates = new Map<StackComponent, StackEvent>(); private readonly stackStates = new Map<StackComponent, StackEvent>();
private readonly listeners: Listeners = { private readonly listeners: Listeners = {
@@ -241,6 +292,7 @@ class MockBackend implements Backend {
private readonly notesVariant: NotesVariant, private readonly notesVariant: NotesVariant,
) { ) {
this.status = initialStatus(scenario); this.status = initialStatus(scenario);
this.conflicts = scenario === "sync-conflicts" ? structuredClone(MOCK_CONFLICTS) : [];
this.network = this.status.online; this.network = this.status.online;
this.settings = { site_url: SITE_URL, mcp_url: MCP_URL, install_dir: DEFAULT_DIR }; this.settings = { site_url: SITE_URL, mcp_url: MCP_URL, install_dir: DEFAULT_DIR };
@@ -475,6 +527,58 @@ class MockBackend implements Backend {
return this.snapshot(); return this.snapshot();
} }
private syncCounts(pending: number): SyncStatus {
return {
pending,
conflicts: this.conflicts.filter((c) => c.kind === "conflict").length,
rejected: this.conflicts.filter((c) => c.kind === "rejected").length,
waiting: 0,
};
}
async syncNow(): Promise<void> {
if (!this.status.online) throw "Your DHP is unreachable: changes stay on this PC until the next connected sync.";
if (this.status.phase !== "idle" && this.status.phase !== "error") throw "ODIAN is busy: sync once it has stopped.";
const sent = this.status.sync?.pending ?? 0;
this.commit({ phase: "syncing", error: null });
await this.wait(1400);
const counts = this.syncCounts(0);
this.commit({ phase: "idle", sync: counts });
this.log(
"info",
`Synced with the DHP: ${sent} change(s) sent, 4 received. 0 waiting to send, ${counts.conflicts} conflict(s), ${counts.rejected} refused.`,
);
}
async getSyncConflicts(): Promise<SyncConflict[]> {
await this.wait(300);
return structuredClone(this.conflicts);
}
async resolveSyncConflict(
tbl: string,
key: Record<string, unknown>,
choice: ConflictChoice,
): Promise<SyncConflict[]> {
if (this.status.phase !== "idle" && this.status.phase !== "error") throw "ODIAN is busy: review sync once it has stopped.";
const index = this.conflicts.findIndex((c) => c.tbl === tbl && JSON.stringify(c.key) === JSON.stringify(key));
if (index < 0) throw `Nothing to resolve for ${tbl}.`;
const conflict = this.conflicts[index];
if (choice === "dismiss" && conflict.kind !== "rejected") throw `"dismiss" is not a valid decision for a conflict`;
await this.wait(250);
this.conflicts.splice(index, 1);
const pending = (this.status.sync?.pending ?? 0) + (choice === "mine" || choice === "value" ? 1 : 0);
this.commit({ sync: this.syncCounts(pending) });
const outcome =
choice === "theirs"
? "took the DHP's version"
: choice === "dismiss"
? "kept the refused change on this PC only"
: "kept this PC's version; the next sync sends it";
this.log("info", `${tbl}: ${outcome}`);
return structuredClone(this.conflicts);
}
async pickFolder(): Promise<string | null> { async pickFolder(): Promise<string | null> {
await new Promise((resolve) => setTimeout(resolve, 450)); await new Promise((resolve) => setTimeout(resolve, 450));
return "E:\\ODIAN"; return "E:\\ODIAN";
+51
View File
@@ -7,6 +7,7 @@ export type Phase =
| "signing_in" // browser sign-in in progress | "signing_in" // browser sign-in in progress
| "installing" // downloading + verifying a version | "installing" // downloading + verifying a version
| "restoring" // loading the database snapshot into local Postgres | "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 | "launching" // starting Postgres → API → tile server → Odian.exe
| "running" // Odian.exe is open | "running" // Odian.exe is open
| "error"; | "error";
@@ -37,6 +38,44 @@ export interface Status {
error: string | null; error: string | null;
/** e.g. "D:\\ODIAN". */ /** e.g. "D:\\ODIAN". */
install_dir: string; install_dir: string;
/** Operator-data sync counts from the last sync this session; null before one. */
sync: SyncStatus | null;
/**
* While ODIAN runs: true when its data comes from the DHP (live), false when from
* this PC. null when ODIAN is not running, or the DHP offers no Engine API.
*/
live: boolean | null;
}
export interface SyncStatus {
/** Local edits the DHP has not taken yet. */
pending: number;
/** Records both sides changed: the operator decides. */
conflicts: number;
/** Edits the DHP refused (another operator's record, ...). */
rejected: number;
/** DHP changes this PC could not apply yet. */
waiting: number;
}
/**
* theirs: take the DHP's version. mine: keep this PC's (the next sync sends it).
* value: keep a field-by-field pick. dismiss: keep a refused edit on this PC only.
*/
export type ConflictChoice = "theirs" | "mine" | "value" | "dismiss";
export interface SyncConflict {
/** Database table, e.g. "db_ground_poi". */
tbl: string;
key: Record<string, unknown>;
kind: "conflict" | "rejected";
/** Clashing fields; "*exists": one side deleted it; "*value": changed differently as a whole. */
fields: string[];
base: unknown;
mine: unknown;
theirs: unknown;
/** Why the DHP refused it (rejected only). */
reason: string | null;
} }
export interface Settings { export interface Settings {
@@ -115,6 +154,7 @@ export const MOCK_SCENARIOS = [
"offline-not-installed", "offline-not-installed",
"installing", "installing",
"running", "running",
"sync-conflicts",
"error", "error",
] as const; ] as const;
@@ -152,6 +192,17 @@ export interface Backend {
getSettings(): Promise<Settings>; getSettings(): Promise<Settings>;
setSettings(settings: Settings): Promise<Status>; setSettings(settings: Settings): Promise<Status>;
pickFolder(): Promise<string | null>; pickFolder(): Promise<string | null>;
/** Push and pull operator data now; only while ODIAN is stopped. */
syncNow(): Promise<void>;
/** What the operator has to decide, oldest first. */
getSyncConflicts(): Promise<SyncConflict[]>;
/** Records one decision; resolves with what is left to decide. */
resolveSyncConflict(
tbl: string,
key: Record<string, unknown>,
choice: ConflictChoice,
value?: unknown,
): Promise<SyncConflict[]>;
onStatus(handler: (status: Status) => void): Promise<Unlisten>; onStatus(handler: (status: Status) => void): Promise<Unlisten>;
onInstallProgress(handler: (progress: InstallProgress) => void): Promise<Unlisten>; onInstallProgress(handler: (progress: InstallProgress) => void): Promise<Unlisten>;