M4
This commit is contained in:
+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";
|
||||
|
||||
Reference in New Issue
Block a user