Skip to content

Commit 952ef36

Browse files
committed
feat(data): detect stale imported reference data
Version imported reference-data semantics independently from the database schema and feed reader-version mismatches through the existing data drift prompt.\n\nCloses #162
1 parent 1964dfd commit 952ef36

13 files changed

Lines changed: 217 additions & 36 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { DatabaseSync } from "node:sqlite";
2+
import { expect, test } from "@playwright/test";
3+
import { activeProjectDbFile, goto } from "./helpers";
4+
5+
test("an older imported-data format reuses the shared drift prompt", async ({ page }) => {
6+
const file = activeProjectDbFile();
7+
const db = new DatabaseSync(file);
8+
const original = db
9+
.prepare("SELECT value FROM meta WHERE key = 'data_format_version'")
10+
.get() as { value: string | null } | undefined;
11+
db.prepare(
12+
"INSERT INTO meta (key, value) VALUES ('data_format_version', '0') ON CONFLICT(key) DO UPDATE SET value = '0'",
13+
).run();
14+
db.close();
15+
16+
try {
17+
await goto(page, "/");
18+
const dialog = page.getByRole("dialog", { name: "Reference data is out of date" });
19+
await expect(dialog).toBeVisible();
20+
await expect(dialog).toContainText("PyOps now reads the Factorio dump differently");
21+
await expect(dialog).toContainText(/Imported data format: v0 · Current reader: v\d+/);
22+
23+
await dialog.getByRole("button", { name: "Ignore for now" }).click();
24+
await expect(dialog).toBeHidden();
25+
await expect(page.locator("nav").getByRole("button", { name: "Data stale" })).toBeVisible();
26+
} finally {
27+
const restore = new DatabaseSync(file);
28+
if (original) {
29+
restore
30+
.prepare(
31+
"INSERT INTO meta (key, value) VALUES ('data_format_version', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
32+
)
33+
.run(original.value);
34+
} else {
35+
restore.prepare("DELETE FROM meta WHERE key = 'data_format_version'").run();
36+
}
37+
restore.close();
38+
}
39+
});

app/src/components/app-nav.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,14 @@ import { driftModal } from "../lib/drift-store";
1414
import { Button } from "#/components/ui/button.tsx";
1515
import { Tooltip } from "#/components/ui/tooltip.tsx";
1616

17-
/** Persistent re-entry point for the data-sync modal: a small warning-toned chip in the
18-
* nav whenever the game's mods have drifted from the project's reference data
19-
* (so dismissing the popup doesn't strand it). Hidden when data is in sync. */
17+
/** Persistent re-entry point for the data-sync modal: a small warning-toned chip
18+
* whenever the game's mods or PyOps' data reader have drifted from the project's
19+
* reference data. Hidden when data is in sync. */
2020
function DataDriftIndicator() {
2121
const drift = useQuery({ queryKey: ["modDrift"], queryFn: () => modDriftFn() });
2222
if (!drift.data?.needsRedump) return null;
2323
return (
24-
<Tooltip content="The game's mods changed since your last data sync — click to review and re-sync.">
24+
<Tooltip content="Reference data no longer matches the current mods or data reader — click to review and re-sync.">
2525
<Button
2626
variant="ghost"
2727
onClick={() => driftModal.open()}

app/src/components/drift-modal.tsx

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,10 @@ const writeDismissed = (sig: string) => {
4949
}
5050
};
5151

52-
/** The guided data-sync experience: a themed modal that pops when mod drift is
53-
* detected (or is opened from the nav / Settings), lets you ignore or re-sync, and
54-
* then walks the dump as a step-by-step progress flow ending in a summary. Mounted
55-
* once in the root; it owns the periodic drift check and the sync polling. */
52+
/** The guided data-sync experience: a themed modal that pops when mod or importer
53+
* drift is detected (or is opened from the nav / Settings), lets you ignore or
54+
* re-sync, and then walks the dump as a step-by-step progress flow ending in a
55+
* summary. Mounted once in the root; it owns the periodic drift check and polling. */
5656
export function DriftModal() {
5757
const qc = useQueryClient();
5858
const isOpen = useDriftModalOpen();
@@ -97,8 +97,12 @@ export function DriftModal() {
9797
prevConnected.current = connected;
9898
}, [connected, qc]);
9999

100-
// Auto-open once per drift signature (until it's dismissed or resolved by a sync).
101-
const sig = drift.data?.needsRedump ? JSON.stringify(drift.data.drift) : null;
100+
// Auto-open once per combined drift signature (until dismissed or resolved).
101+
// Include the reader versions so each future format bump prompts exactly once,
102+
// even when the mod set itself has not changed.
103+
const sig = drift.data?.needsRedump
104+
? JSON.stringify({ mods: drift.data.drift, dataFormat: drift.data.dataFormat })
105+
: null;
102106
useEffect(() => {
103107
if (sig && readDismissed() !== sig) driftModal.open();
104108
}, [sig]);
@@ -123,6 +127,8 @@ export function DriftModal() {
123127
if (!isOpen) return null;
124128

125129
const hasDrift = !!drift.data?.needsRedump;
130+
const hasModDrift = !!drift.data?.modsChanged;
131+
const hasDataFormatDrift = !!drift.data?.dataFormat.stale;
126132
const gameUp = gameRunning.data?.running === true; // can't sync while the game runs
127133
const running = RUNNING.has(phase);
128134
const view: "prompt" | "running" | "done" | "error" = running
@@ -170,8 +176,8 @@ export function DriftModal() {
170176
<HelpButton title="Data drift" className="mr-7 ml-auto">
171177
<p>
172178
PyOps plans against a snapshot of the game&apos;s prototype data (recipes, items,
173-
machines, techs). When the game&apos;s mod set changes, that snapshot drifts out of
174-
date and this dialog offers a re-sync.
179+
machines, techs). When the game&apos;s mod set or PyOps&apos; data reader changes,
180+
that snapshot drifts out of date and this dialog offers a re-sync.
175181
</p>
176182
<p>
177183
A re-sync launches a headless copy of Factorio to re-dump the data, then imports it.
@@ -189,7 +195,10 @@ export function DriftModal() {
189195
{view === "prompt" && (
190196
<PromptBody
191197
hasDrift={hasDrift}
198+
hasModDrift={hasModDrift}
199+
hasDataFormatDrift={hasDataFormatDrift}
192200
drift={drift.data?.drift ?? null}
201+
dataFormat={drift.data?.dataFormat}
193202
icons={icons}
194203
setIcons={setIcons}
195204
gameUp={gameUp}
@@ -272,13 +281,19 @@ const TITLES: Record<"running" | "done" | "error", TitleSpec> = {
272281

273282
function PromptBody({
274283
hasDrift,
284+
hasModDrift,
285+
hasDataFormatDrift,
275286
drift,
287+
dataFormat,
276288
icons,
277289
setIcons,
278290
gameUp,
279291
}: {
280292
hasDrift: boolean;
293+
hasModDrift: boolean;
294+
hasDataFormatDrift: boolean;
281295
drift: Parameters<typeof DriftChanges>[0]["drift"];
296+
dataFormat?: { current: number; imported: number | null; stale: boolean };
282297
icons: boolean;
283298
setIcons: (v: boolean) => void;
284299
gameUp: boolean;
@@ -292,11 +307,24 @@ function PromptBody({
292307
</Callout>
293308
)}
294309
<p className="text-sm text-muted-foreground">
295-
{hasDrift
296-
? "The game's mods changed since your last sync — re-sync to plan against the current data."
297-
: "Re-dump the game's prototype data from the current mods and import it."}
310+
{hasModDrift && hasDataFormatDrift
311+
? "The game's mods and PyOps' data reader changed since your last sync. Re-sync to rebuild this project's reference data."
312+
: hasModDrift
313+
? "The game's mods changed since your last sync — re-sync to plan against the current data."
314+
: hasDataFormatDrift
315+
? "PyOps now reads the Factorio dump differently than the version that built this project's reference data. Re-sync to rebuild it with the current reader."
316+
: hasDrift
317+
? "This project's reference data needs to be rebuilt before planning continues."
318+
: "Re-dump the game's prototype data from the current mods and import it."}
298319
</p>
299-
{hasDrift && (
320+
{hasDataFormatDrift && dataFormat && (
321+
<div className="border border-warning/30 bg-warning/10 p-2 text-sm text-warning">
322+
Imported data format:{" "}
323+
{dataFormat.imported == null ? "unversioned" : `v${dataFormat.imported}`}
324+
{" · "}Current reader: v{dataFormat.current}
325+
</div>
326+
)}
327+
{hasModDrift && drift && (
300328
<div className="max-h-48 overflow-y-auto border border-border bg-muted/20 p-2">
301329
<DriftChanges drift={drift} />
302330
</div>

app/src/db/import-factorio.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ import { tmpdir } from "node:os";
33
import { join } from "node:path";
44
import Database from "better-sqlite3";
55
import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
6+
import {
7+
REFERENCE_DATA_FORMAT_META_KEY,
8+
REFERENCE_DATA_FORMAT_VERSION,
9+
} from "../lib/data-format.ts";
610
import { importFactorioDump } from "./import-factorio.ts";
711
import { type TestDb, makeTestDb } from "./test-helpers.ts";
812

@@ -90,6 +94,18 @@ describe("importFactorioDump — Factorio 2.1 product probability", () => {
9094
});
9195
});
9296

97+
describe("importFactorioDump — data format version", () => {
98+
it("records the reader version after a successful two-pass import", () => {
99+
const db = runImport({ recipe: {} });
100+
const stored = db
101+
.prepare("SELECT value FROM meta WHERE key = ?")
102+
.pluck()
103+
.get(REFERENCE_DATA_FORMAT_META_KEY);
104+
expect(stored).toBe(String(REFERENCE_DATA_FORMAT_VERSION));
105+
db.close();
106+
});
107+
});
108+
93109
describe("importFactorioDump — machine footprints", () => {
94110
it("derives placed tile dimensions from array and named selection boxes", () => {
95111
const db = runImport({

app/src/db/import-factorio.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ import { homedir } from "node:os";
1515
import { dirname, join } from "node:path";
1616
import { synthesizePass2 } from "./synthesize.ts";
1717
import { temperatureFedDrain, type TempFedFluid } from "./fluid-energy.ts";
18+
import {
19+
REFERENCE_DATA_FORMAT_META_KEY,
20+
REFERENCE_DATA_FORMAT_VERSION,
21+
} from "../lib/data-format.ts";
1822
import { PROJECTS_DIR } from "../server/paths.server.ts";
1923
import { configureSqliteConnection } from "../server/provision.ts";
2024

@@ -177,6 +181,7 @@ export type ImportSummary = {
177181
dump: string;
178182
dbUrl: string;
179183
ms: number;
184+
dataFormatVersion: number;
180185
counts: Record<string, number>;
181186
};
182187

@@ -623,8 +628,17 @@ export function importFactorioDump(
623628
null,
624629
parseSI,
625630
});
631+
// Record compatibility only after both importer passes complete. A failed
632+
// upgrade therefore keeps the previous version stale and prompts another sync.
633+
ins.meta.run(REFERENCE_DATA_FORMAT_META_KEY, String(REFERENCE_DATA_FORMAT_VERSION));
626634
const count = (t: string) => (db.prepare(`SELECT count(*) c FROM ${t}`).get() as { c: number }).c;
627635
const counts = Object.fromEntries(COUNT_TABLES.map((t) => [t, count(t)]));
628636
db.close();
629-
return { dump: DUMP, dbUrl: DB_URL, ms: Date.now() - t0, counts: { ...counts, ...synthetic } };
637+
return {
638+
dump: DUMP,
639+
dbUrl: DB_URL,
640+
ms: Date.now() - t0,
641+
dataFormatVersion: REFERENCE_DATA_FORMAT_VERSION,
642+
counts: { ...counts, ...synthetic },
643+
};
630644
}

app/src/lib/data-format.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { describe, expect, it } from "vite-plus/test";
2+
import { REFERENCE_DATA_FORMAT_VERSION, referenceDataFormatStatus } from "./data-format.ts";
3+
4+
describe("referenceDataFormatStatus", () => {
5+
it("accepts reference data imported by the current reader", () => {
6+
expect(referenceDataFormatStatus(String(REFERENCE_DATA_FORMAT_VERSION), true)).toEqual({
7+
current: REFERENCE_DATA_FORMAT_VERSION,
8+
imported: REFERENCE_DATA_FORMAT_VERSION,
9+
stale: false,
10+
});
11+
});
12+
13+
it("marks unversioned, older, newer, and malformed imports stale", () => {
14+
expect(referenceDataFormatStatus(undefined, true).stale).toBe(true);
15+
expect(referenceDataFormatStatus("0", true).stale).toBe(true);
16+
expect(referenceDataFormatStatus(String(REFERENCE_DATA_FORMAT_VERSION + 1), true).stale).toBe(
17+
true,
18+
);
19+
expect(referenceDataFormatStatus("not-a-version", true).stale).toBe(true);
20+
});
21+
22+
it("does not nag an empty project that already needs its initial sync", () => {
23+
expect(referenceDataFormatStatus(undefined, false).stale).toBe(false);
24+
});
25+
});

app/src/lib/data-format.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* Version of PyOps' normalized Factorio reference-data interpretation.
3+
*
4+
* Bump this whenever an importer or synthesis change means an existing project's
5+
* reference tables must be rebuilt from the game dump. This is deliberately
6+
* separate from SQLite migrations: migrations describe storage shape, while
7+
* this version describes the meaning of imported rows.
8+
*/
9+
export const REFERENCE_DATA_FORMAT_VERSION = 1;
10+
export const REFERENCE_DATA_FORMAT_META_KEY = "data_format_version";
11+
12+
export type ReferenceDataFormatStatus = {
13+
current: number;
14+
imported: number | null;
15+
stale: boolean;
16+
};
17+
18+
/** Missing versions represent imports made before format tracking existed. Empty
19+
* projects are setup cases, not stale imports, so they do not trigger drift. */
20+
export function referenceDataFormatStatus(
21+
stored: string | null | undefined,
22+
hasReferenceData: boolean,
23+
): ReferenceDataFormatStatus {
24+
const parsed = stored == null ? Number.NaN : Number(stored);
25+
const imported = Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null;
26+
return {
27+
current: REFERENCE_DATA_FORMAT_VERSION,
28+
imported,
29+
stale: hasReferenceData && imported !== REFERENCE_DATA_FORMAT_VERSION,
30+
};
31+
}

app/src/routes/settings.tsx

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -151,8 +151,8 @@ function GameDataTab() {
151151
<CardHeader className="justify-between">
152152
<CardTitle>Reference data</CardTitle>
153153
{drift.data?.needsRedump && (
154-
<Tooltip content="The game's enabled mods or their versions changed since the last sync">
155-
<Badge variant="destructive">Stale — mods changed</Badge>
154+
<Tooltip content="The game's mods or PyOps' data reader changed since the last sync">
155+
<Badge variant="destructive">Stale — re-sync needed</Badge>
156156
</Tooltip>
157157
)}
158158
</CardHeader>
@@ -173,6 +173,17 @@ function GameDataTab() {
173173
{status.data.currentFingerprint &&
174174
` · Current mod list ${status.data.currentFingerprint}`}
175175
</div>
176+
{drift.data?.dataFormat && (
177+
<div
178+
className={`text-xs ${drift.data.dataFormat.stale ? "text-warning" : "text-muted-foreground"}`}
179+
>
180+
Imported data format{" "}
181+
{drift.data.dataFormat.imported == null
182+
? "unversioned"
183+
: `v${drift.data.dataFormat.imported}`}
184+
{" · "}Current reader v{drift.data.dataFormat.current}
185+
</div>
186+
)}
176187
</>
177188
)}
178189
<Button onClick={() => driftModal.open()} className="mt-1">
@@ -295,7 +306,7 @@ function ModDriftCard({ data }: { data: Awaited<ReturnType<typeof modDriftFn>> |
295306
);
296307
}
297308
const d = data.drift;
298-
if (!data.needsRedump) {
309+
if (!data.modsChanged || !d) {
299310
return (
300311
<Card>
301312
<CardHeader className="justify-between">

app/src/server/dump.server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -599,7 +599,7 @@ export function startDataSync(opts: { icons?: boolean } = {}): SyncState {
599599
step("import", "importing dump into sqlite");
600600
const summary = importFactorioDump({ dbUrl: currentDatabaseFile() });
601601
state.log.push(
602-
`imported ${summary.counts.recipes} recipes / ${summary.counts.items} items in ${summary.ms}ms`,
602+
`imported ${summary.counts.recipes} recipes / ${summary.counts.items} items with data reader v${summary.dataFormatVersion} in ${summary.ms}ms`,
603603
);
604604
if (icons) {
605605
step("atlas", "rebuilding icon atlas");

app/src/server/factorio.ts

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
} from "../lib/goals";
1212
import { extractRecipeToBlockDocs, withRecipeSet } from "../lib/block-doc";
1313
import type { FactoryFlowQualifier } from "../lib/factory-flow.ts";
14+
import { referenceDataFormatStatus } from "../lib/data-format.ts";
1415

1516
/**
1617
* Server functions exposing the query layer to the client. Server-only modules
@@ -1093,14 +1094,16 @@ export const dataStatusFn = createServerFn({ method: "GET" }).handler(async () =
10931094
};
10941095
});
10951096

1096-
/** Mod-drift check: compare the game's CURRENT mod set (live from the mods dir)
1097-
* against the baseline this project's data was dumped from (#28, `meta.mod_list`),
1098-
* by name AND version. Returns the categorized drift plus `needsRedump` — the
1099-
* signal that the reference data no longer matches the game and a re-dump is due.
1097+
/** Reference-data drift check: compare the game's CURRENT mod set (live from the
1098+
* mods dir) against the baseline this project's data was dumped from (#28,
1099+
* `meta.mod_list`), and compare the importer format recorded in project metadata
1100+
* against the app's current reader. Returns both causes plus `needsRedump` — the
1101+
* shared signal that the reference data needs to be rebuilt.
11001102
* Cheap (two small file reads), so it's safe to poll on app start, on project
11011103
* switch (a full reload re-runs it), on bridge reconnect, and periodically. */
11021104
export const modDriftFn = createServerFn({ method: "GET" }).handler(async () => {
11031105
const metaMap = q.metaAll();
1106+
const dataFormat = referenceDataFormatStatus(metaMap.data_format_version, q.stats().recipes > 0);
11041107
let baseline: dump.ModEntry[] | null = null;
11051108
if (metaMap.mod_list) {
11061109
try {
@@ -1115,12 +1118,14 @@ export const modDriftFn = createServerFn({ method: "GET" }).handler(async () =>
11151118
} catch {
11161119
current = null; // factorio dir missing — can't compare, don't nag
11171120
}
1118-
if (!baseline || !current)
1119-
return { haveBaseline: !!baseline, drift: null, needsRedump: false } as const;
1121+
const drift = baseline && current ? dump.diffMods(baseline, current) : null;
1122+
const modsChanged = baseline && current ? dump.redumpNeeded(baseline, current) : false;
11201123
return {
1121-
haveBaseline: true,
1122-
drift: dump.diffMods(baseline, current),
1123-
needsRedump: dump.redumpNeeded(baseline, current),
1124+
haveBaseline: !!baseline,
1125+
drift,
1126+
modsChanged,
1127+
dataFormat,
1128+
needsRedump: modsChanged || dataFormat.stale,
11241129
};
11251130
});
11261131

0 commit comments

Comments
 (0)