M4
This commit is contained in:
@@ -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<String> = 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<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)]
|
||||
|
||||
@@ -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<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 {
|
||||
@@ -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\<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`)
|
||||
|
||||
| Event | Payload | When |
|
||||
|
||||
@@ -7,6 +7,7 @@ use tauri_plugin_opener::OpenerExt;
|
||||
use crate::launcher::{Launcher, Status};
|
||||
use crate::settings::Settings;
|
||||
use crate::stack::StackEvent;
|
||||
use crate::sync::Conflict;
|
||||
|
||||
type CmdResult<T> = Result<T, String>;
|
||||
|
||||
@@ -89,6 +90,29 @@ pub async fn get_stack(launcher: State<'_, Launcher>) -> CmdResult<Vec<StackEven
|
||||
Ok(launcher.stack_states())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn sync_now(app: AppHandle, launcher: State<'_, Launcher>) -> CmdResult<()> {
|
||||
launcher.sync_now(&app).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_sync_conflicts(app: AppHandle, launcher: State<'_, Launcher>) -> CmdResult<Vec<Conflict>> {
|
||||
launcher.sync_conflicts(&app).await
|
||||
}
|
||||
|
||||
/// `choice`: "theirs", "mine", "value" (with `value`) or "dismiss" (refused edits only).
|
||||
#[tauri::command]
|
||||
pub async fn resolve_sync_conflict(
|
||||
app: AppHandle,
|
||||
launcher: State<'_, Launcher>,
|
||||
tbl: String,
|
||||
key: serde_json::Value,
|
||||
choice: String,
|
||||
value: Option<serde_json::Value>,
|
||||
) -> CmdResult<Vec<Conflict>> {
|
||||
launcher.resolve_sync_conflict(&app, tbl, key, choice, value).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_settings(launcher: State<'_, Launcher>) -> CmdResult<Settings> {
|
||||
Ok(launcher.settings().await)
|
||||
|
||||
+356
-2
@@ -1,6 +1,7 @@
|
||||
//! The launcher's live state behind every command, and the `status` event.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -15,7 +16,8 @@ use crate::localdb::{self, Pg};
|
||||
use crate::settings::{normalize_url, Settings};
|
||||
use crate::site::{self, SessionRefresh, TokenCheck};
|
||||
use crate::stack::{self, Layout, RunningStack, StackConfig, StackEvent};
|
||||
use crate::state::{self, LauncherState};
|
||||
use crate::state::{self, LauncherState, SyncOrigin};
|
||||
use crate::sync::{self, SyncError, SyncStatus};
|
||||
use crate::tokens::{self, SiteToken};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
@@ -26,6 +28,8 @@ pub enum Phase {
|
||||
SigningIn,
|
||||
Installing,
|
||||
Restoring,
|
||||
/// Exchanging operator data with the DHP (ODIAN is not running meanwhile).
|
||||
Syncing,
|
||||
Launching,
|
||||
Running,
|
||||
Error,
|
||||
@@ -50,6 +54,11 @@ pub struct Status {
|
||||
pub phase: Phase,
|
||||
pub error: Option<String>,
|
||||
pub install_dir: String,
|
||||
/// Operator-data sync counts from the last sync this session; None before one.
|
||||
pub sync: Option<SyncStatus>,
|
||||
/// While ODIAN runs: true when its data is coming from the DHP, false when from
|
||||
/// this PC. None when ODIAN is not running, or the DHP offers no Engine API.
|
||||
pub live: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
@@ -90,6 +99,11 @@ struct Inner {
|
||||
token: Option<SiteToken>,
|
||||
phase: Phase,
|
||||
error: Option<String>,
|
||||
sync: Option<SyncStatus>,
|
||||
/// The DHP's Engine API, learned from its health answer; kept once seen so a launch
|
||||
/// during a blip still knows where live mode will forward.
|
||||
engine_url: Option<String>,
|
||||
live: Option<bool>,
|
||||
}
|
||||
|
||||
impl Inner {
|
||||
@@ -111,6 +125,8 @@ impl Inner {
|
||||
phase: self.phase,
|
||||
error: self.error.clone(),
|
||||
install_dir: self.settings.install_dir.clone(),
|
||||
sync: self.sync.clone(),
|
||||
live: self.live,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -140,7 +156,7 @@ impl Launcher {
|
||||
.map_err(|e| format!("cannot start the HTTP client: {e}"))?;
|
||||
Ok(Launcher {
|
||||
http,
|
||||
inner: Mutex::new(Inner { settings, state, online: false, available: None, token, phase: Phase::Checking, error: None }),
|
||||
inner: Mutex::new(Inner { settings, state, online: false, available: None, token, phase: Phase::Checking, error: None, sync: None, engine_url: None, live: None }),
|
||||
stack: Mutex::new(None),
|
||||
stack_states: std::sync::Mutex::new(Vec::new()),
|
||||
sign_in_cancel: Notify::new(),
|
||||
@@ -253,6 +269,10 @@ impl Launcher {
|
||||
let mut inner = self.inner.lock().await;
|
||||
inner.online = probe.online;
|
||||
inner.available = probe.version;
|
||||
// Keep the last known address while offline: it is where live mode resumes.
|
||||
if probe.engine_url.is_some() {
|
||||
inner.engine_url = probe.engine_url;
|
||||
}
|
||||
}
|
||||
// Offline nothing is checked or forgotten: the remembered operator still launches.
|
||||
if let (true, Some(token)) = (probe.online, token) {
|
||||
@@ -604,6 +624,8 @@ impl Launcher {
|
||||
restored.map_err(|e| format!("The database restore failed: {e}"))?;
|
||||
let mut inner = self.inner.lock().await;
|
||||
inner.state.db_snapshot = Some(db.snapshot.clone());
|
||||
inner.state.sync_origin =
|
||||
db.sync.as_ref().map(|origin| SyncOrigin { db_id: origin.db_id.clone(), horizon: origin.horizon.clone() });
|
||||
inner.state.save(install_dir)?;
|
||||
}
|
||||
|
||||
@@ -682,6 +704,7 @@ impl Launcher {
|
||||
pg_password: inner.state.pg_password.clone(),
|
||||
install_id: inner.state.install_id.clone(),
|
||||
tile_sets,
|
||||
upstream_url: inner.engine_url.clone(),
|
||||
}
|
||||
};
|
||||
if let Ok(mut states) = self.stack_states.lock() {
|
||||
@@ -697,6 +720,19 @@ impl Launcher {
|
||||
}
|
||||
|
||||
async fn run_stack(&self, app: &AppHandle, config: StackConfig) {
|
||||
// Take in the other operators' work before the API loads it. Never blocks the launch.
|
||||
self.set_phase(app, Phase::Syncing).await;
|
||||
self.sync_run(app, &config.layout, &config.pg_password, &config.install_id, true).await;
|
||||
self.set_phase(app, Phase::Launching).await;
|
||||
|
||||
// Live mode: hand the API the DHP token before it starts, so a launch made online
|
||||
// comes up on the DHP's data instead of switching a moment later.
|
||||
let live_mode = config.upstream_url.is_some();
|
||||
if live_mode {
|
||||
let token = self.site_access_token(app).await.ok();
|
||||
self.write_upstream_token(&config.layout, token.as_deref());
|
||||
}
|
||||
|
||||
let emit = |event: StackEvent| self.record_stack(app, event);
|
||||
let running = match stack::start(&self.http, &config, &emit).await {
|
||||
Ok(running) => running,
|
||||
@@ -709,14 +745,31 @@ impl Launcher {
|
||||
*self.stack.lock().await = Some(running);
|
||||
self.set_phase(app, Phase::Running).await;
|
||||
|
||||
let live_cancel = CancellationToken::new();
|
||||
if live_mode {
|
||||
let handle = app.clone();
|
||||
let cancel = live_cancel.clone();
|
||||
let (layout, password, install_id) =
|
||||
(config.layout.clone(), config.pg_password.clone(), config.install_id.clone());
|
||||
tauri::async_runtime::spawn(async move {
|
||||
handle.state::<Launcher>().live_loop(&handle, layout, password, install_id, cancel).await;
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(odian) = odian {
|
||||
stack::wait_exit(&odian).await;
|
||||
}
|
||||
// Odian.exe closed. If Stop already took the stack, it stopped the rest too.
|
||||
live_cancel.cancel();
|
||||
self.write_upstream_token(&config.layout, None);
|
||||
self.inner.lock().await.live = None;
|
||||
let taken = self.stack.lock().await.take();
|
||||
if let Some(mut running) = taken {
|
||||
running.stop(&emit).await;
|
||||
}
|
||||
// Send this session's work while it is fresh; pulling waits for the next launch.
|
||||
self.set_phase(app, Phase::Syncing).await;
|
||||
self.sync_run(app, &config.layout, &config.pg_password, &config.install_id, false).await;
|
||||
self.finish(app, None).await;
|
||||
}
|
||||
|
||||
@@ -729,6 +782,307 @@ impl Launcher {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── operator-data sync ──────────────────────────────────────────────────
|
||||
|
||||
/// Sync with the DHP while the local Engine API is not running. Never fails the caller:
|
||||
/// offline, signed out or refused, the edits stay on this PC for the next connected run.
|
||||
async fn sync_run(&self, app: &AppHandle, layout: &Layout, pg_password: &str, install_id: &str, pull: bool) {
|
||||
let sql_dir = layout.sync_sql_dir();
|
||||
if !sync::schema_shipped(&sql_dir) {
|
||||
return; // a version from before sync
|
||||
}
|
||||
let (online, site_url, origin) = {
|
||||
let inner = self.inner.lock().await;
|
||||
(inner.online, inner.settings.site_url.clone(), inner.state.sync_origin.clone())
|
||||
};
|
||||
if !online {
|
||||
self.log(app, "info", "Offline: this PC's changes stay here until the next connected launch.");
|
||||
return;
|
||||
}
|
||||
|
||||
let pg = Pg::new(layout, pg_password);
|
||||
let db = pg.clone();
|
||||
let started = match tauri::async_runtime::spawn_blocking(move || -> Result<bool, String> {
|
||||
let started = !db.running();
|
||||
if started {
|
||||
db.start()?;
|
||||
}
|
||||
Ok(started)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(Ok(started)) => started,
|
||||
Ok(Err(message)) => {
|
||||
self.log(app, "warn", format!("Sync skipped: {message}"));
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
self.log(app, "warn", format!("Sync skipped: {e}"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match self.sync_with_dhp(app, &pg, &sql_dir, &site_url, install_id, origin, pull).await {
|
||||
Ok(report) => {
|
||||
let s = &report.status;
|
||||
self.log(
|
||||
app,
|
||||
"info",
|
||||
format!(
|
||||
"Synced with the DHP: {} change(s) sent, {} received. {} waiting to send, {} conflict(s), {} refused.",
|
||||
report.accepted, report.received, s.pending, s.conflicts, s.rejected
|
||||
),
|
||||
);
|
||||
if s.conflicts + s.rejected > 0 {
|
||||
self.log(app, "warn", "Some changes need your decision: review them under Sync.");
|
||||
}
|
||||
self.inner.lock().await.sync = Some(report.status);
|
||||
}
|
||||
Err(message) => self.log(app, "warn", format!("Sync did not complete: {message}")),
|
||||
}
|
||||
|
||||
if started {
|
||||
let _ = tauri::async_runtime::spawn_blocking(move || pg.stop()).await;
|
||||
}
|
||||
self.emit(app).await;
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn sync_with_dhp(
|
||||
&self,
|
||||
app: &AppHandle,
|
||||
pg: &Pg,
|
||||
sql_dir: &Path,
|
||||
site_url: &str,
|
||||
install_id: &str,
|
||||
origin: Option<SyncOrigin>,
|
||||
pull: bool,
|
||||
) -> Result<sync::Report, String> {
|
||||
let token = self.site_access_token(app).await?;
|
||||
let (db, dir) = (pg.clone(), sql_dir.to_path_buf());
|
||||
let joined = tauri::async_runtime::spawn_blocking(move || {
|
||||
sync::load_schema(&db, &dir)?;
|
||||
sync::joined_db_id(&db)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
|
||||
if joined.is_none() {
|
||||
let (db_id, since) = match origin {
|
||||
Some(origin) => (origin.db_id, origin.horizon),
|
||||
None => {
|
||||
let dhp = sync::Dhp { http: &self.http, site_url, token: &token };
|
||||
let greeting = sync::hello(&dhp).await.map_err(|e| e.to_string())?;
|
||||
self.log(app, "warn", "This PC's database predates sync: it takes the DHP's changes from now on.");
|
||||
greeting
|
||||
}
|
||||
};
|
||||
let db = pg.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || sync::join(&db, &db_id, &since)).await.map_err(|e| e.to_string())??;
|
||||
}
|
||||
|
||||
let dhp = sync::Dhp { http: &self.http, site_url, token: &token };
|
||||
match sync::run(pg, &dhp, install_id, pull).await {
|
||||
Err(SyncError::Unauthorized) => {
|
||||
let token = self.renew(app).await?;
|
||||
let dhp = sync::Dhp { http: &self.http, site_url, token: &token };
|
||||
sync::run(pg, &dhp, install_id, pull).await.map_err(|e| e.to_string())
|
||||
}
|
||||
other => other.map_err(|e| e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Give the local API the DHP token it forwards with — or take it away, which sends
|
||||
/// the API back to this PC's data without restarting anything.
|
||||
fn write_upstream_token(&self, layout: &Layout, token: Option<&str>) {
|
||||
let path = stack::upstream_token_file(layout);
|
||||
match token {
|
||||
Some(token) => {
|
||||
if let Err(e) = crate::fsutil::write_atomic(&path, token.as_bytes()) {
|
||||
log::warn!("cannot hand the local API its DHP token: {e}");
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask the local API where ODIAN's data is coming from right now.
|
||||
async fn read_live_state(&self) {
|
||||
let url = format!("http://127.0.0.1:{}/offline/status", stack::API_PORT);
|
||||
let live = match self.http.get(url).timeout(Duration::from_secs(3)).send().await {
|
||||
Ok(response) => response
|
||||
.json::<serde_json::Value>()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|body| body.get("mode").and_then(|mode| mode.as_str()).map(|mode| mode == "live")),
|
||||
Err(_) => None,
|
||||
};
|
||||
self.inner.lock().await.live = live;
|
||||
}
|
||||
|
||||
/// While ODIAN runs against a DHP: keep its token fresh, notice the connection coming
|
||||
/// and going, send this PC's offline work the moment it returns, and keep this PC's
|
||||
/// own copy warm so an abrupt drop lands on fresh data rather than an old snapshot.
|
||||
async fn live_loop(
|
||||
&self,
|
||||
app: &AppHandle,
|
||||
layout: Layout,
|
||||
pg_password: String,
|
||||
install_id: String,
|
||||
cancel: CancellationToken,
|
||||
) {
|
||||
const TICK: Duration = Duration::from_secs(30);
|
||||
const MIRROR_EVERY: Duration = Duration::from_secs(120);
|
||||
let (site_url, mut was_online) = {
|
||||
let inner = self.inner.lock().await;
|
||||
(inner.settings.site_url.clone(), inner.online)
|
||||
};
|
||||
let mut mirrored = Instant::now();
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = cancel.cancelled() => break,
|
||||
_ = tokio::time::sleep(TICK) => {}
|
||||
}
|
||||
let probe = site::probe(&self.http, &site_url).await;
|
||||
{
|
||||
let mut inner = self.inner.lock().await;
|
||||
inner.online = probe.online;
|
||||
if probe.engine_url.is_some() {
|
||||
inner.engine_url = probe.engine_url.clone();
|
||||
}
|
||||
}
|
||||
if !probe.online {
|
||||
if was_online {
|
||||
self.log(app, "warn", "The DHP is out of reach: ODIAN is working from this PC. Your changes stay here until it is back.");
|
||||
}
|
||||
was_online = false;
|
||||
self.read_live_state().await;
|
||||
self.emit(app).await;
|
||||
continue;
|
||||
}
|
||||
let Ok(token) = self.site_access_token(app).await else {
|
||||
self.read_live_state().await;
|
||||
continue;
|
||||
};
|
||||
if !was_online {
|
||||
// Hold the API on this PC's data until this PC's offline work has reached the
|
||||
// DHP: nothing may be edited there on top of edits that have not arrived yet.
|
||||
self.write_upstream_token(&layout, None);
|
||||
self.log(app, "info", "Back on the DHP: sending the changes made offline…");
|
||||
self.sync_run(app, &layout, &pg_password, &install_id, false).await;
|
||||
mirrored = Instant::now();
|
||||
}
|
||||
self.write_upstream_token(&layout, Some(&token));
|
||||
if !was_online {
|
||||
self.log(app, "info", "ODIAN is back on the DHP's data.");
|
||||
was_online = true;
|
||||
}
|
||||
if mirrored.elapsed() >= MIRROR_EVERY {
|
||||
self.sync_run(app, &layout, &pg_password, &install_id, true).await;
|
||||
mirrored = Instant::now();
|
||||
}
|
||||
self.read_live_state().await;
|
||||
self.emit(app).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Sync on demand while ODIAN is stopped.
|
||||
pub async fn sync_now(&self, app: &AppHandle) -> Result<(), String> {
|
||||
let (layout, password, install_id) = {
|
||||
let mut inner = self.inner.lock().await;
|
||||
if !matches!(inner.phase, Phase::Idle | Phase::Error) {
|
||||
return Err("ODIAN is busy: sync once it has stopped.".to_string());
|
||||
}
|
||||
let version = inner.state.installed_version.clone().ok_or("ODIAN is not installed.")?;
|
||||
if inner.state.pg_password.is_empty() || inner.state.db_snapshot.is_none() {
|
||||
return Err("The local database was never set up. Reinstall from the DHP or a drive.".to_string());
|
||||
}
|
||||
inner.phase = Phase::Syncing;
|
||||
inner.error = None;
|
||||
(Layout::new(&inner.settings.install_path(), &version), inner.state.pg_password.clone(), inner.state.install_id.clone())
|
||||
};
|
||||
self.emit(app).await;
|
||||
self.sync_run(app, &layout, &password, &install_id, true).await;
|
||||
self.finish(app, None).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run `work` on this PC's database while ODIAN is stopped, starting Postgres just for it.
|
||||
async fn with_local_db<T: Send + 'static>(
|
||||
&self,
|
||||
app: &AppHandle,
|
||||
work: impl FnOnce(&Pg) -> Result<T, String> + Send + 'static,
|
||||
) -> Result<T, String> {
|
||||
let (layout, password) = {
|
||||
let mut inner = self.inner.lock().await;
|
||||
if !matches!(inner.phase, Phase::Idle | Phase::Error) {
|
||||
return Err("ODIAN is busy: review sync once it has stopped.".to_string());
|
||||
}
|
||||
let version = inner.state.installed_version.clone().ok_or("ODIAN is not installed.")?;
|
||||
if inner.state.pg_password.is_empty() {
|
||||
return Err("The local database was never set up.".to_string());
|
||||
}
|
||||
inner.phase = Phase::Syncing;
|
||||
(Layout::new(&inner.settings.install_path(), &version), inner.state.pg_password.clone())
|
||||
};
|
||||
self.emit(app).await;
|
||||
let pg = Pg::new(&layout, &password);
|
||||
let result = tauri::async_runtime::spawn_blocking(move || {
|
||||
let started = !pg.running();
|
||||
if started {
|
||||
pg.start()?;
|
||||
}
|
||||
let result = work(&pg);
|
||||
if started {
|
||||
let _ = pg.stop();
|
||||
}
|
||||
result
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|result| result);
|
||||
{
|
||||
let mut inner = self.inner.lock().await;
|
||||
if inner.phase == Phase::Syncing {
|
||||
inner.phase = Phase::Idle;
|
||||
}
|
||||
}
|
||||
self.emit(app).await;
|
||||
result
|
||||
}
|
||||
|
||||
/// What the operator has to decide, oldest first.
|
||||
pub async fn sync_conflicts(&self, app: &AppHandle) -> Result<Vec<sync::Conflict>, String> {
|
||||
self
|
||||
.with_local_db(app, |pg| match sync::joined_db_id(pg)? {
|
||||
None => Ok(Vec::new()),
|
||||
Some(_) => sync::conflicts(pg),
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Record one decision; returns what is left to decide.
|
||||
pub async fn resolve_sync_conflict(
|
||||
&self,
|
||||
app: &AppHandle,
|
||||
tbl: String,
|
||||
key: serde_json::Value,
|
||||
choice: String,
|
||||
value: Option<serde_json::Value>,
|
||||
) -> Result<Vec<sync::Conflict>, String> {
|
||||
let (left, status) = self
|
||||
.with_local_db(app, move |pg| {
|
||||
sync::resolve(pg, &tbl, &key, &choice, value.as_ref())?;
|
||||
Ok((sync::conflicts(pg)?, sync::status(pg)?))
|
||||
})
|
||||
.await?;
|
||||
self.inner.lock().await.sync = Some(status);
|
||||
self.emit(app).await;
|
||||
Ok(left)
|
||||
}
|
||||
|
||||
/// The launcher is exiting: stop the stack cleanly rather than leave Postgres to crash recovery.
|
||||
pub async fn shutdown(&self) {
|
||||
if let Ok(slot) = self.install_cancel.lock() {
|
||||
|
||||
@@ -10,6 +10,7 @@ mod settings;
|
||||
mod site;
|
||||
mod stack;
|
||||
mod state;
|
||||
mod sync;
|
||||
mod tokens;
|
||||
|
||||
use tauri::Manager;
|
||||
@@ -53,6 +54,9 @@ pub fn run() {
|
||||
commands::get_settings,
|
||||
commands::set_settings,
|
||||
commands::pick_folder,
|
||||
commands::sync_now,
|
||||
commands::get_sync_conflicts,
|
||||
commands::resolve_sync_conflict,
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building the launcher")
|
||||
|
||||
+45
-10
@@ -107,6 +107,11 @@ impl Pg {
|
||||
if self.running() {
|
||||
return Ok(());
|
||||
}
|
||||
// Another install's Postgres on the shared port would leave only "exit code 1" in the log.
|
||||
if crate::stack::port_taken(PG_PORT) {
|
||||
return Err("Another ODIAN install is already running on this PC: it holds the database port. Stop it in the launcher window that started it, then try again."
|
||||
.to_string());
|
||||
}
|
||||
let mut cmd = self.tool("pg_ctl");
|
||||
cmd
|
||||
.arg("start")
|
||||
@@ -151,6 +156,34 @@ impl Pg {
|
||||
Self::run(cmd, "psql")
|
||||
}
|
||||
|
||||
/// Run a script on the `odian` database through psql's stdin and return what it printed
|
||||
/// (unaligned, tuples only). For sync, whose JSON payloads are too large for a command line.
|
||||
pub fn script(&self, sql: &str) -> Result<String, String> {
|
||||
use std::io::Write;
|
||||
let port = PG_PORT.to_string();
|
||||
let mut cmd = self.tool("psql");
|
||||
cmd
|
||||
.env("PGCLIENTENCODING", "UTF8")
|
||||
.args(["-X", "-q", "-A", "-t", "-v", "ON_ERROR_STOP=1", "-h", "127.0.0.1", "-p", port.as_str()])
|
||||
.args(["-U", PG_USER, "-d", PG_DATABASE, "-f", "-"])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
let mut child = cmd.spawn().map_err(|e| format!("cannot run psql: {e}"))?;
|
||||
let mut stdin = child.stdin.take().ok_or("cannot write to psql")?;
|
||||
// Feed from a thread: psql filling its stdout pipe while we still write would deadlock.
|
||||
let body = sql.as_bytes().to_vec();
|
||||
let writer = std::thread::spawn(move || stdin.write_all(&body));
|
||||
let out = child.wait_with_output().map_err(|e| format!("psql failed: {e}"))?;
|
||||
let _ = writer.join();
|
||||
if out.status.success() {
|
||||
return Ok(String::from_utf8_lossy(&out.stdout).into_owned());
|
||||
}
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
let tail: String = stderr.chars().rev().take(1500).collect::<Vec<_>>().into_iter().rev().collect();
|
||||
Err(format!("psql failed ({}): {}", out.status, tail.trim()))
|
||||
}
|
||||
|
||||
/// Replace the `odian` database with a snapshot. Only for a PC whose restore never
|
||||
/// completed: anything an operator did in the old database is dropped.
|
||||
pub fn restore(
|
||||
@@ -225,24 +258,26 @@ impl Pg {
|
||||
.args(["-h", "127.0.0.1", "-p", port.as_str(), "-U", PG_USER, "-d", PG_DATABASE])
|
||||
.args(["--no-owner", "--no-privileges", "--exit-on-error"]);
|
||||
if pre_data {
|
||||
cmd.arg("-L").arg(self.pre_data_list(&dump)?);
|
||||
// A fresh database already has schema public; --exit-on-error would stop on the duplicate.
|
||||
let drop = |line: &str| line.contains(" SCHEMA - public ") || line.contains(" COMMENT - SCHEMA public ");
|
||||
cmd.arg("-L").arg(self.toc_without(&dump, "pre-data.list", &drop)?);
|
||||
} else if file.section == "post-data" {
|
||||
// A DHP running sync has marking triggers on its tables; they call sync.mark(), which
|
||||
// this database gets only when the launcher loads the version's sync schema.
|
||||
let drop = |line: &str| line.contains(" TRIGGER ") && line.contains(" sync_mark_");
|
||||
cmd.arg("-L").arg(self.toc_without(&dump, "post-data.list", &drop)?);
|
||||
}
|
||||
cmd.arg(&dump);
|
||||
Self::run(cmd, &format!("pg_restore {}", file.path)).map(|_| ())
|
||||
}
|
||||
|
||||
/// The pre-data table of contents without `CREATE SCHEMA public`: a fresh database
|
||||
/// already has that schema, and --exit-on-error would stop on the duplicate.
|
||||
fn pre_data_list(&self, dump: &Path) -> Result<PathBuf, String> {
|
||||
/// The dump's table of contents without the entries `drop` names, for `pg_restore -L`.
|
||||
fn toc_without(&self, dump: &Path, list_name: &str, drop: &dyn Fn(&str) -> bool) -> Result<PathBuf, String> {
|
||||
let mut cmd = self.tool("pg_restore");
|
||||
cmd.arg("-l").arg(dump);
|
||||
let listing = Self::run(cmd, "pg_restore -l")?;
|
||||
let kept: String = listing
|
||||
.lines()
|
||||
.filter(|line| !(line.contains(" SCHEMA - public ") || line.contains(" COMMENT - SCHEMA public ")))
|
||||
.map(|line| format!("{line}\n"))
|
||||
.collect();
|
||||
let path = self.logs.join("pre-data.list");
|
||||
let kept: String = listing.lines().filter(|line| !drop(line)).map(|line| format!("{line}\n")).collect();
|
||||
let path = self.logs.join(list_name);
|
||||
std::fs::write(&path, kept).map_err(|e| format!("cannot write {}: {e}", path.display()))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
@@ -11,10 +11,13 @@ pub struct Probe {
|
||||
pub online: bool,
|
||||
/// The site's CURRENT version.
|
||||
pub version: Option<String>,
|
||||
/// The DHP's Engine API, as this PC can reach it: where live mode forwards while
|
||||
/// online. Absent when the DHP offers none, and this PC then serves its own data.
|
||||
pub engine_url: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn probe(http: &reqwest::Client, site_url: &str) -> Probe {
|
||||
let offline = Probe { online: false, version: None };
|
||||
let offline = Probe { online: false, version: None, engine_url: None };
|
||||
if site_url.is_empty() {
|
||||
return offline;
|
||||
}
|
||||
@@ -25,10 +28,8 @@ pub async fn probe(http: &reqwest::Client, site_url: &str) -> Probe {
|
||||
return offline;
|
||||
}
|
||||
let body: serde_json::Value = response.json().await.unwrap_or_default();
|
||||
Probe {
|
||||
online: true,
|
||||
version: body.get("version").and_then(|v| v.as_str()).map(str::to_string),
|
||||
}
|
||||
let text = |key: &str| body.get(key).and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(str::to_string);
|
||||
Probe { online: true, version: text("version"), engine_url: text("engine_api_url") }
|
||||
}
|
||||
|
||||
pub enum TokenCheck {
|
||||
|
||||
+64
-2
@@ -115,6 +115,10 @@ impl Layout {
|
||||
pub fn pg_data(&self) -> PathBuf {
|
||||
self.data_dir().join("pg")
|
||||
}
|
||||
/// The operator-data sync schema this version ships (`sync\*.sql`); absent before M3.
|
||||
pub fn sync_sql_dir(&self) -> PathBuf {
|
||||
self.version_dir().join("sync")
|
||||
}
|
||||
fn python(&self) -> PathBuf {
|
||||
self.app_file(PYTHON_EXE)
|
||||
}
|
||||
@@ -140,6 +144,15 @@ pub struct StackConfig {
|
||||
pub install_id: String,
|
||||
/// (style, dataset) of every tile set the installed version carries.
|
||||
pub tile_sets: Vec<(String, String)>,
|
||||
/// The DHP's Engine API (from its health answer). With one, the local API serves
|
||||
/// the DHP's data while it is reachable; without, this PC always serves its own.
|
||||
pub upstream_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Where the launcher keeps the DHP token the local API forwards with. A file, not an
|
||||
/// env var: the launcher rewrites it whenever it renews, without restarting the stack.
|
||||
pub fn upstream_token_file(layout: &Layout) -> PathBuf {
|
||||
layout.state_dir().join("api").join("upstream.token")
|
||||
}
|
||||
|
||||
/// A process started from a clean environment, in `cwd`, with `search_path` first on PATH.
|
||||
@@ -223,7 +236,26 @@ impl RunningStack {
|
||||
}
|
||||
}
|
||||
|
||||
/// Is something already listening on 127.0.0.1:`port`?
|
||||
pub fn port_taken(port: u16) -> bool {
|
||||
std::net::TcpListener::bind(("127.0.0.1", port)).is_err()
|
||||
}
|
||||
|
||||
/// Every install on this PC uses the same three loopback ports, so only one runs at a time.
|
||||
/// Said plainly here, rather than as "Postgres did not start" once the ports collide.
|
||||
fn check_ports_free() -> Result<(), String> {
|
||||
for (port, what) in [(PG_PORT, "the database"), (API_PORT, "the Engine API"), (TILES_PORT, "the tile server")] {
|
||||
if port_taken(port) {
|
||||
return Err(format!(
|
||||
"Another ODIAN install is already running on this PC ({what} holds 127.0.0.1:{port}). Stop it in the launcher window that started it, then launch again."
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn start(http: &reqwest::Client, config: &StackConfig, emit: Emit<'_>) -> Result<RunningStack, String> {
|
||||
check_ports_free()?;
|
||||
prepare(config)?;
|
||||
let mut running = RunningStack {
|
||||
job: job::Job::new()?,
|
||||
@@ -413,7 +445,7 @@ fn datasets(config: &StackConfig) -> serde_json::Value {
|
||||
fn api_env(config: &StackConfig) -> Vec<(&'static str, String)> {
|
||||
let api = config.layout.state_dir().join("api");
|
||||
let logs = api.join("logs");
|
||||
vec![
|
||||
let mut env = vec![
|
||||
("ODIAN_API_BIND", "127.0.0.1".to_string()),
|
||||
("ODIAN_API_PORT", API_PORT.to_string()),
|
||||
("ODIAN_PG_INI", text(api.join("pg.ini"))),
|
||||
@@ -428,7 +460,15 @@ fn api_env(config: &StackConfig) -> Vec<(&'static str, String)> {
|
||||
("ODIAN_API_LOGDIR", text(logs.clone())),
|
||||
("ODIAN_API_LOG", text(logs.join("odian_api.log"))),
|
||||
("ODIAN_API_THREAD_STATE", text(logs.join("thread_state.txt"))),
|
||||
]
|
||||
];
|
||||
// Live mode: with a DHP to forward to, the local API serves the DHP's data while it
|
||||
// answers, and this PC's own the moment it stops. The token file may not exist yet
|
||||
// (signed out, or offline) — the API simply stays local until it does.
|
||||
if let Some(url) = &config.upstream_url {
|
||||
env.push(("ODIAN_UPSTREAM_URL", url.clone()));
|
||||
env.push(("ODIAN_UPSTREAM_TOKEN_FILE", text(upstream_token_file(&config.layout))));
|
||||
}
|
||||
env
|
||||
}
|
||||
|
||||
fn tiles_env(config: &StackConfig) -> Vec<(&'static str, String)> {
|
||||
@@ -504,6 +544,14 @@ pub fn installed_tile_sets(layout: &Layout) -> Result<Vec<(String, String)>, Str
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn a_bound_port_reads_as_taken() {
|
||||
let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).expect("bind");
|
||||
let port = listener.local_addr().expect("addr").port();
|
||||
assert!(super::port_taken(port), "a port someone is listening on must read as taken");
|
||||
drop(listener);
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
fn config(tile_sets: &[(&str, &str)]) -> StackConfig {
|
||||
@@ -514,6 +562,7 @@ mod tests {
|
||||
pg_password: "pw".to_string(),
|
||||
install_id: "install".to_string(),
|
||||
tile_sets: tile_sets.iter().map(|(s, d)| (s.to_string(), d.to_string())).collect(),
|
||||
upstream_url: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -521,6 +570,19 @@ mod tests {
|
||||
env.iter().find(|(k, _)| *k == key).map(|(_, v)| v.as_str())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_api_is_told_about_the_dhp_only_when_there_is_one() {
|
||||
let alone = config(&[]);
|
||||
assert_eq!(value(&api_env(&alone), "ODIAN_UPSTREAM_URL"), None, "no DHP: this PC serves its own data");
|
||||
assert_eq!(value(&api_env(&alone), "ODIAN_UPSTREAM_TOKEN_FILE"), None);
|
||||
|
||||
let mut live = config(&[]);
|
||||
live.upstream_url = Some("http://dhp.example:30684".to_string());
|
||||
let env = api_env(&live);
|
||||
assert_eq!(value(&env, "ODIAN_UPSTREAM_URL"), Some("http://dhp.example:30684"));
|
||||
assert!(value(&env, "ODIAN_UPSTREAM_TOKEN_FILE").is_some_and(|path| path.ends_with("upstream.token")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_resolves_manifest_paths_inside_the_version() {
|
||||
let layout = Layout::new(Path::new(r"D:\ODIAN"), "0.52.0");
|
||||
|
||||
@@ -30,6 +30,18 @@ pub struct LauncherState {
|
||||
/// database holds no operator work yet and may be (re)created from a snapshot.
|
||||
#[serde(default)]
|
||||
pub db_snapshot: Option<String>,
|
||||
/// Where that snapshot starts syncing (the manifest's `db.sync`). None for a snapshot
|
||||
/// from before sync: its first sync then starts from the DHP's state at that moment.
|
||||
#[serde(default)]
|
||||
pub sync_origin: Option<SyncOrigin>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct SyncOrigin {
|
||||
/// The DHP database the snapshot came from.
|
||||
pub db_id: String,
|
||||
/// The first transaction the snapshot may be missing.
|
||||
pub horizon: String,
|
||||
}
|
||||
|
||||
fn state_dir(install_dir: &Path) -> PathBuf {
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
import KeyArt from "./components/KeyArt.svelte";
|
||||
import NewsPanel from "./components/NewsPanel.svelte";
|
||||
import SettingsDrawer from "./components/SettingsDrawer.svelte";
|
||||
import SyncPanel from "./components/SyncPanel.svelte";
|
||||
import StatusBar from "./components/StatusBar.svelte";
|
||||
import TitleBar from "./components/TitleBar.svelte";
|
||||
import { launcher } from "./lib/launcher.svelte";
|
||||
@@ -31,6 +32,10 @@
|
||||
<div class="activity-slot"><ActivityPanel /></div>
|
||||
{/if}
|
||||
|
||||
{#if launcher.syncOpen}
|
||||
<div class="sync-slot"><SyncPanel /></div>
|
||||
{/if}
|
||||
|
||||
<SettingsDrawer bind:open={settingsOpen} />
|
||||
</div>
|
||||
|
||||
@@ -83,6 +88,14 @@
|
||||
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) {
|
||||
.hub {
|
||||
grid-template-columns: minmax(300px, 360px) minmax(0, 1fr) minmax(340px, 380px);
|
||||
@@ -92,6 +105,9 @@
|
||||
.activity-slot {
|
||||
left: 24px;
|
||||
}
|
||||
.sync-slot {
|
||||
right: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 700px) {
|
||||
|
||||
@@ -60,6 +60,8 @@
|
||||
return { label: s?.installed ? "Updating" : "Installing", tone: "accent", pulse: true, meta: stage };
|
||||
case "restoring":
|
||||
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":
|
||||
return { label: "Update required", tone: "warn", pulse: false, meta: `${s?.installed} → ${s?.available}` };
|
||||
case "launch":
|
||||
@@ -165,6 +167,21 @@
|
||||
Offline: ODIAN <strong>{s?.installed}</strong> runs on local data and map tiles.
|
||||
{/if}
|
||||
</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"}
|
||||
<div class="progress">
|
||||
<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}
|
||||
</div>
|
||||
<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"}
|
||||
<StackList stack={launcher.stack} phase={s?.phase ?? "launching"} />
|
||||
<button class="btn btn-danger wide" disabled={launcher.stopping || busy !== null} onclick={() => launcher.stop()}>
|
||||
@@ -371,6 +396,16 @@
|
||||
.secondary.small {
|
||||
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 {
|
||||
color: var(--ink-2);
|
||||
font-weight: 500;
|
||||
|
||||
@@ -39,6 +39,21 @@
|
||||
<span>Installed</span>
|
||||
<strong class="mono">{s.installed ?? "none"}</strong>
|
||||
</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}>
|
||||
<Icon name="folder" size={14} />
|
||||
<span class="mono selectable path">{s.install_dir}</span>
|
||||
@@ -122,6 +137,28 @@
|
||||
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 {
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
SignInResult,
|
||||
StackEvent,
|
||||
Status,
|
||||
SyncConflict,
|
||||
Unlisten,
|
||||
} from "./types";
|
||||
|
||||
@@ -52,6 +53,10 @@ function createTauriBackend(): Backend {
|
||||
getSettings: () => invoke<Settings>("get_settings"),
|
||||
setSettings: (settings) => invoke<Status>("set_settings", { settings }),
|
||||
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),
|
||||
onInstallProgress: (handler) => subscribe<InstallProgress>("install-progress", handler),
|
||||
|
||||
@@ -4,6 +4,7 @@ import { backend } from "./backend";
|
||||
import { errorText, hostOf } from "./format";
|
||||
import { SIGN_IN_CANCELLED } from "./types";
|
||||
import type {
|
||||
ConflictChoice,
|
||||
InstallProgress,
|
||||
LogEvent,
|
||||
LogLevel,
|
||||
@@ -12,6 +13,7 @@ import type {
|
||||
StackComponent,
|
||||
StackEvent,
|
||||
Status,
|
||||
SyncConflict,
|
||||
Unlisten,
|
||||
} from "./types";
|
||||
|
||||
@@ -24,6 +26,7 @@ export type ActionKind =
|
||||
| "signed_out_offline"
|
||||
| "installing"
|
||||
| "restoring"
|
||||
| "syncing"
|
||||
| "update"
|
||||
| "launch"
|
||||
| "stack"
|
||||
@@ -45,6 +48,8 @@ export function deriveAction(s: Status | null): ActionKind {
|
||||
return "installing";
|
||||
case "restoring":
|
||||
return "restoring";
|
||||
case "syncing":
|
||||
return "syncing";
|
||||
case "launching":
|
||||
case "running":
|
||||
return "stack";
|
||||
@@ -75,6 +80,7 @@ const LOCKED_PHASES: ReadonlySet<Phase> = new Set([
|
||||
"signing_in",
|
||||
"installing",
|
||||
"restoring",
|
||||
"syncing",
|
||||
"launching",
|
||||
"running",
|
||||
]);
|
||||
@@ -104,6 +110,10 @@ class Launcher {
|
||||
busy = $state<string | null>(null);
|
||||
refreshing = $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>({
|
||||
key: null,
|
||||
loading: false,
|
||||
@@ -117,6 +127,8 @@ class Launcher {
|
||||
/** Settings can't change while something is installing or running. */
|
||||
locked = $derived(this.status ? LOCKED_PHASES.has(this.status.phase) : false);
|
||||
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(
|
||||
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> {
|
||||
try {
|
||||
return await backend.pickFolder();
|
||||
|
||||
+106
-2
@@ -2,7 +2,7 @@
|
||||
// `npm run dev`. Same shapes as docs/CONTRACT.md, driven by timers.
|
||||
//
|
||||
// ?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
|
||||
// &speed=4 run timers 4x faster
|
||||
// ¬es=no-roadmap notes without a Roadmap
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
SIGN_IN_CANCELLED,
|
||||
type Backend,
|
||||
type InstallProgress,
|
||||
type ConflictChoice,
|
||||
type InstallStage,
|
||||
type LogEvent,
|
||||
type LogLevel,
|
||||
@@ -24,6 +25,8 @@ import {
|
||||
type StackComponent,
|
||||
type StackEvent,
|
||||
type Status,
|
||||
type SyncConflict,
|
||||
type SyncStatus,
|
||||
type Unlisten,
|
||||
} from "./types";
|
||||
|
||||
@@ -131,6 +134,42 @@ const STAGE_DONE: Record<InstallStage, string> = {
|
||||
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 = {
|
||||
status: Status;
|
||||
"install-progress": InstallProgress;
|
||||
@@ -173,6 +212,8 @@ function initialStatus(scenario: MockScenario): Status {
|
||||
phase: "idle",
|
||||
error: null,
|
||||
install_dir: DEFAULT_DIR,
|
||||
sync: null,
|
||||
live: null,
|
||||
};
|
||||
switch (scenario) {
|
||||
case "first-run":
|
||||
@@ -193,7 +234,15 @@ function initialStatus(scenario: MockScenario): Status {
|
||||
Object.assign(s, { operator: OPERATOR, signed_in: true, phase: "installing" });
|
||||
break;
|
||||
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;
|
||||
case "error":
|
||||
Object.assign(s, {
|
||||
@@ -225,6 +274,8 @@ class MockBackend implements Backend {
|
||||
private job = 0;
|
||||
private booted = 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. */
|
||||
private readonly stackStates = new Map<StackComponent, StackEvent>();
|
||||
private readonly listeners: Listeners = {
|
||||
@@ -241,6 +292,7 @@ class MockBackend implements Backend {
|
||||
private readonly notesVariant: NotesVariant,
|
||||
) {
|
||||
this.status = initialStatus(scenario);
|
||||
this.conflicts = scenario === "sync-conflicts" ? structuredClone(MOCK_CONFLICTS) : [];
|
||||
this.network = this.status.online;
|
||||
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();
|
||||
}
|
||||
|
||||
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> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 450));
|
||||
return "E:\\ODIAN";
|
||||
|
||||
@@ -7,6 +7,7 @@ export 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";
|
||||
@@ -37,6 +38,44 @@ export interface Status {
|
||||
error: string | null;
|
||||
/** e.g. "D:\\ODIAN". */
|
||||
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 {
|
||||
@@ -115,6 +154,7 @@ export const MOCK_SCENARIOS = [
|
||||
"offline-not-installed",
|
||||
"installing",
|
||||
"running",
|
||||
"sync-conflicts",
|
||||
"error",
|
||||
] as const;
|
||||
|
||||
@@ -152,6 +192,17 @@ export interface Backend {
|
||||
getSettings(): Promise<Settings>;
|
||||
setSettings(settings: Settings): Promise<Status>;
|
||||
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>;
|
||||
onInstallProgress(handler: (progress: InstallProgress) => void): Promise<Unlisten>;
|
||||
|
||||
Reference in New Issue
Block a user