Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions document/06-traps.md
Original file line number Diff line number Diff line change
Expand Up @@ -824,3 +824,29 @@ The general shape: **a predicate that combines a capability with a warning will
be read by somebody who wanted only one of them.** When a name like `usable`
covers both "can" and "should", the call sites that mean "can" are already
wrong and nothing will say so.

## A reset that also reset the device's memory of the server

Reported as "Bench won't let me delete my data": backup, erase everything,
confirm, and the next backup held all of it again, three times over.

`resetAll` wrote `EMPTY_DATA` over the store, settings included, and the sync
record lives in the settings. The device therefore forgot which server version
it had last agreed with, and an empty device that has never agreed with a
server is a fresh install. `decideSync` answers a fresh install by pulling the
server's copy, which is the right answer to the question it was asked. It was
asked the wrong question. The erase came back within seconds.

The rescue guard made it worse in the same direction. It saw every record go,
set them all aside, and the notice offered them back.

Fixed by keeping `settings.sync` through the reset, so the device is in step
with the server and holding a change, and the empty document goes up. The
rescue copy is skipped for this one deletion, the way `wipeLocal` already skips
it, because it sits behind its own confirmation and is the one loss the app
knows was meant.

The general shape: **resetting "everything" includes things that are not the
user's data but the device's knowledge of where it stands.** Before writing a
blank over a whole record, ask which of its fields describe the content and
which describe the relationship to something outside it.
5 changes: 4 additions & 1 deletion src/app/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import { TONE_SOLID } from "@/components/ui";
import type { AppData } from "@/lib/types";
import { UpdateButton } from "@/components/UpdateButton";
import { useLang } from "@/lib/i18n";
import { useSyncState } from "@/lib/sync/state";

export default function SettingsPage() {
const hydrated = useStore((s) => s.hydrated);
Expand All @@ -64,6 +65,8 @@ export default function SettingsPage() {
const updateSettings = useStore((s) => s.updateSettings);
const exportData = useStore((s) => s.exportData);
const resetAll = useStore((s) => s.resetAll);
// Erasing a synced device empties the account, and the warning has to say so.
const syncing = useSyncState((s) => s.engine != null);
const custom = useStore((s) => s.customPeptides);

const [message, setMessage] = useState<{ tone: "info" | "danger"; text: string } | null>(null);
Expand Down Expand Up @@ -252,7 +255,7 @@ export default function SettingsPage() {
<Card className="space-y-3 border-[var(--rose)]/35 p-4">
<SectionLabel>{t("settings_erase")}</SectionLabel>
<p className="text-[13.5px] leading-relaxed text-[var(--muted)]">
{t("settings_erase_desc")}
{syncing ? t("settings_erase_desc_synced") : t("settings_erase_desc")}
</p>
{confirmingReset ? (
<div className="flex flex-wrap gap-2.5">
Expand Down
4 changes: 4 additions & 0 deletions src/lib/i18n/translations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -523,6 +523,7 @@ export const TRANSLATIONS = {
settings_bring_data_back: "To bring data back in, your own export, or a file from another app, use **Import from another app** above.",
settings_currently_holding: "Currently holding {doses}, {protocols} and {vials}.",
settings_erase_desc: "Deletes every protocol, dose and vial on this device. There is no undo and no copy anywhere else, export first if there is any chance you want this back.",
settings_erase_desc_synced: "Deletes every protocol, dose and vial on this device and on the sync server, so every device signed in to this account is emptied too. There is no undo, export first if there is any chance you want this back.",
settings_keep_my_data: "Keep my data",
settings_erase_confirm: "Yes, erase all {n} records",
settings_exported_note: "Exported. Keep the file somewhere you control.",
Expand Down Expand Up @@ -1950,6 +1951,7 @@ export const TRANSLATIONS = {
settings_bring_data_back: "Um Daten zurückzuholen, deinen eigenen Export oder eine Datei aus einer anderen App, nutze oben **Import aus einer anderen App**.",
settings_currently_holding: "Aktuell gespeichert: {doses}, {protocols} und {vials}.",
settings_erase_desc: "Löscht jedes Protokoll, jede Dosis und jede Ampulle auf diesem Gerät. Es gibt kein Zurück und keine Kopie anderswo, exportiere zuerst, falls du das je zurückwillst.",
settings_erase_desc_synced: "Löscht jedes Protokoll, jede Dosis und jede Ampulle auf diesem Gerät und auf dem Sync-Server, damit wird auch jedes Gerät geleert, das mit diesem Konto angemeldet ist. Es gibt kein Zurück, exportiere zuerst, falls du das je zurückwillst.",
settings_keep_my_data: "Meine Daten behalten",
settings_erase_confirm: "Ja, alle {n} Einträge löschen",
settings_exported_note: "Exportiert. Bewahre die Datei an einem Ort auf, den du kontrollierst.",
Expand Down Expand Up @@ -3401,6 +3403,7 @@ export const TRANSLATIONS = {
settings_bring_data_back: "Za vrnitev podatkov, svojega izvoza ali datoteke iz druge aplikacije, uporabi **Uvoz iz druge aplikacije** zgoraj.",
settings_currently_holding: "Trenutno hrani: {doses}, {protocols} in {vials}.",
settings_erase_desc: "Izbriše vsak protokol, dozo in vialko na tej napravi. Ni razveljavitve in ni kopije nikjer drugje, zato najprej izvozi, če obstaja možnost, da to še želiš.",
settings_erase_desc_synced: "Izbriše vsak protokol, dozo in vialko na tej napravi in na strežniku za sinhronizacijo, zato se izprazni tudi vsaka naprava, ki je prijavljena v ta račun. Ni razveljavitve, zato najprej izvozi, če obstaja možnost, da to še želiš.",
settings_keep_my_data: "Obdrži moje podatke",
settings_erase_confirm: "Da, izbriši vseh zapisov: {n}",
settings_exported_note: "Izvoženo. Datoteko hrani nekje, kar je pod tvojim nadzorom.",
Expand Down Expand Up @@ -4914,6 +4917,7 @@ export const TRANSLATIONS = {
settings_bring_data_back: "Aby wczytać dane z powrotem, własny eksport albo plik z innej aplikacji, użyj **Import z innej aplikacji** powyżej.",
settings_currently_holding: "Obecnie przechowuje: {doses}, {protocols} i {vials}.",
settings_erase_desc: "Usuwa każdy protokół, dawkę i fiolkę na tym urządzeniu. Nie ma cofnięcia ani kopii nigdzie indziej, więc najpierw wyeksportuj, jeśli jest szansa, że będziesz tego chcieć.",
settings_erase_desc_synced: "Usuwa każdy protokół, dawkę i fiolkę na tym urządzeniu i na serwerze synchronizacji, więc opróżnione zostanie też każde urządzenie zalogowane na to konto. Nie ma cofnięcia, więc najpierw wyeksportuj, jeśli jest szansa, że będziesz tego chcieć.",
settings_keep_my_data: "Zachowaj moje dane",
settings_erase_confirm: "Tak, usuń wszystkie rekordy: {n}",
settings_exported_note: "Wyeksportowano. Trzymaj plik tam, gdzie masz kontrolę.",
Expand Down
55 changes: 55 additions & 0 deletions src/lib/store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { useStore } from "./store";
import { decideSync } from "./sync/decide";
import { DEFAULT_SETTINGS } from "./types";

describe("resetAll", () => {
/*
* Reported as "they always come back": erase, confirm, and the next backup
* held everything again. Resetting `sync` along with the other settings made
* a synced device look like a fresh install, and a fresh install takes the
* server's copy.
*/
const synced = () => {
useStore.getState().updateSettings({
currency: "GBP",
sync: { url: "https://bench.example", username: "tofs", remoteSeenAt: 1_700_000_000_000 },
});
useStore.getState().addLog({ peptideId: "bpc-157", at: 1_700_000_000_000, doseMcg: 250, route: "subcutaneous" });
};

it("empties the data", () => {
synced();
expect(useStore.getState().logs.length).toBeGreaterThan(0);
useStore.getState().resetAll();
expect(useStore.getState().logs).toEqual([]);
expect(useStore.getState().protocols).toEqual([]);
});

it("keeps the device's place on the sync server and resets every other setting", () => {
synced();
useStore.getState().resetAll();
const { settings } = useStore.getState();
expect(settings.sync?.remoteSeenAt).toBe(1_700_000_000_000);
expect(settings.currency).toBe(DEFAULT_SETTINGS.currency);
});

it("leaves a synced device sending the erase rather than taking the server's copy back", () => {
synced();
useStore.getState().resetAll();
const seen = useStore.getState().settings.sync?.remoteSeenAt ?? null;
const action = decideSync({
remoteSeenAt: seen,
remoteUpdatedAt: 1_700_000_000_000,
dirty: true,
localEmpty: true,
});
expect(action.kind).toBe("push");
});

it("adds no sync record to a device that never had one", () => {
useStore.getState().updateSettings({ sync: undefined });
useStore.getState().resetAll();
expect(useStore.getState().settings.sync).toBeUndefined();
});
});
35 changes: 34 additions & 1 deletion src/lib/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -864,7 +864,40 @@ export const useStore = create<StoreState>()(
// field this build has never heard of still reaches the file.
return { ...documentFrom(s), settings, halfLifeOverrides: s.halfLifeOverrides ?? {} };
},
resetAll: () => set({ ...EMPTY_DATA }),
/*
* Erase everything, and mean it.
*
* Reported as "they always come back": backup, erase, confirm, and the
* next backup held everything again. Two things undid the erase.
*
* Sync, first and within seconds. Writing EMPTY_DATA wholesale reset the
* settings too, `sync` with them, so the device forgot which server
* version it had last agreed with. An empty device that has never agreed
* with a server is a fresh install, and `decideSync` pulls the server's
* copy into it, which is exactly right for a fresh install and exactly
* wrong here. Keeping `sync` leaves the device in step with the server and
* holding a change, so the empty document is sent up and every device on
* the account follows. That is what "erase everything" means once there
* is an account; erasing only this browser is what signing out does.
*
* The rescue copy, second. The guard in `setItem` saw every record go and
* set them aside, and the notice then offered them back. That guard is for
* losses nobody meant, and this is the one deletion the app knows was
* meant, behind its own confirmation, so it goes the way `wipeLocal` does:
* no baseline to compare against, and no leftover copy from an earlier
* attempt. `lastWritten` is cleared before `set`, because the persist
* write that compares against it starts inside `set`.
*/
resetAll: () => {
lastWritten = null;
void clearRescue();
set((s) => ({
...EMPTY_DATA,
settings: s.settings.sync
? { ...EMPTY_DATA.settings, sync: s.settings.sync }
: EMPTY_DATA.settings,
}));
},

putRecordsBack: (rescue) => {
set((s) => restoreLost(documentFrom(s), rescue));
Expand Down
Loading