Skip to content

Commit d46cefe

Browse files
committed
fix(web): repair fizruk daily-log SQLite read path and make dual-write batches atomic
Two data-layer fixes from the fizruk module audit: - sqliteReader selected the raw `entry_at` column while the cached shape (and `rowToDailyLog`) read `row.at`, so every cached daily-log entry carried `at: undefined`. That poisoned the dual-write baseline built from the cache (constant re-upsert churn) and would surface as lost timestamps once reads flip to SQLite. Alias `entry_at AS at` + regression test. - applyFizrukDualWriteOps applied ops one-by-one with per-op catch, so a mid-batch failure left SQLite on a state that never existed in LS. The batch now runs in a BEGIN/COMMIT transaction with ROLLBACK on failure; the next dual-write tick retries the diff wholesale. Test covers the rollback path.
1 parent 61e87c9 commit d46cefe

4 files changed

Lines changed: 94 additions & 13 deletions

File tree

apps/web/src/modules/fizruk/lib/dualWrite/__tests__/adapter.test.ts

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -306,14 +306,13 @@ describe("applyFizrukDualWriteOps", () => {
306306

307307
// --- Error handling ---
308308

309-
it("logs errors and continues processing remaining ops", async () => {
309+
it("counts every applied op in a clean batch", async () => {
310310
const warnings: unknown[] = [];
311311
const ops: FizrukDualWriteOp[] = [
312312
{
313313
kind: "measurement-upsert",
314314
measurement: { id: "m1", at: "2026-05-01T08:00:00Z" },
315315
},
316-
// This will also succeed since we're just testing the counter
317316
{
318317
kind: "measurement-upsert",
319318
measurement: { id: "m2", at: "2026-05-01T09:00:00Z", weightKg: 75 },
@@ -329,6 +328,48 @@ describe("applyFizrukDualWriteOps", () => {
329328
expect(result.errored).toBe(0);
330329
});
331330

331+
it("rolls the whole batch back when one op fails", async () => {
332+
const warnings: Array<{ msg: string; meta?: unknown }> = [];
333+
const failingClient: typeof handle.client = {
334+
...handle.client,
335+
exec: (sql) => handle.client.exec(sql),
336+
all: (sql, params) => handle.client.all(sql, params),
337+
run: (sql, params) => {
338+
if (Array.isArray(params) && params.includes("m-fail")) {
339+
throw new Error("forced failure");
340+
}
341+
return handle.client.run(sql, params);
342+
},
343+
};
344+
345+
const ops: FizrukDualWriteOp[] = [
346+
{
347+
kind: "measurement-upsert",
348+
measurement: { id: "m-ok", at: "2026-05-01T08:00:00Z" },
349+
},
350+
{
351+
kind: "measurement-upsert",
352+
measurement: { id: "m-fail", at: "2026-05-01T09:00:00Z" },
353+
},
354+
];
355+
356+
const result = await applyFizrukDualWriteOps(failingClient, ops, {
357+
userId: UID,
358+
clientTs: TS1,
359+
logger: (_level, msg, meta) => warnings.push({ msg, meta }),
360+
});
361+
362+
expect(result).toEqual({ applied: 0, errored: 2, skipped: 0 });
363+
expect(warnings.some((w) => w.msg.includes("rolled back"))).toBe(true);
364+
365+
// The successful first op must NOT survive — SQLite stays on the
366+
// pre-batch state instead of a half-applied diff.
367+
const rows = await handle.client.all<Record<string, unknown>>(
368+
"SELECT id FROM fizruk_measurements",
369+
);
370+
expect(rows).toEqual([]);
371+
});
372+
332373
// --- Stage 12 / PR #070f-dualwrite — Daily log ops ---
333374

334375
it("upserts a daily-log entry with all scalar fields", async () => {

apps/web/src/modules/fizruk/lib/dualWrite/adapter.ts

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -57,24 +57,35 @@ export async function applyFizrukDualWriteOps(
5757
}
5858
const logger = options.logger ?? DEFAULT_LOGGER;
5959
let applied = 0;
60-
let errored = 0;
6160
let skipped = 0;
6261

63-
for (const op of ops) {
64-
try {
62+
// The whole batch is atomic: a diff describes one LS-state transition, so
63+
// applying half of it would leave SQLite on a state that never existed in
64+
// LS and silently diverge until the next full diff. On any failure the
65+
// transaction rolls back and the batch is retried wholesale by the next
66+
// dual-write tick.
67+
await client.exec("BEGIN");
68+
try {
69+
for (const op of ops) {
6570
const outcome = await applyOne(client, op, options);
6671
if (outcome === "applied") applied += 1;
6772
else skipped += 1;
68-
} catch (err) {
69-
errored += 1;
70-
logger("warn", "dual-write op failed", {
71-
op: op.kind,
72-
error: err instanceof Error ? err.message : String(err),
73-
});
7473
}
74+
await client.exec("COMMIT");
75+
} catch (err) {
76+
try {
77+
await client.exec("ROLLBACK");
78+
} catch {
79+
/* rollback failure is unrecoverable here; the original error wins */
80+
}
81+
logger("warn", "dual-write batch rolled back", {
82+
ops: ops.length,
83+
error: err instanceof Error ? err.message : String(err),
84+
});
85+
return { applied: 0, errored: ops.length, skipped: 0 };
7586
}
7687

77-
return { applied, errored, skipped };
88+
return { applied, errored: 0, skipped };
7889
}
7990

8091
type ApplyOutcome = "applied" | "skipped";

apps/web/src/modules/fizruk/lib/sqliteReader.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,35 @@ describe("refreshFizrukSqliteState", () => {
205205
expect(cache.measurements).toHaveLength(1);
206206
expect(cache.measurements[0]!.id).toBe("m-1");
207207
});
208+
209+
it("hydrates daily-log entries with their timestamp intact", async () => {
210+
// Regression: the table column is `entry_at` while the cached shape is
211+
// `at` — without the SQL alias the timestamp silently became undefined.
212+
const ops: FizrukDualWriteOp[] = [
213+
{
214+
kind: "daily-log-upsert",
215+
entry: {
216+
id: "dl-1",
217+
at: "2026-05-01T07:00:00Z",
218+
weightKg: 81.2,
219+
sleepHours: 7.5,
220+
energyLevel: 4,
221+
mood: 3,
222+
note: "ранковий запис",
223+
},
224+
},
225+
];
226+
await applyFizrukDualWriteOps(handle.client, ops, {
227+
userId: UID,
228+
clientTs: TS,
229+
logger: silentLogger,
230+
});
231+
232+
const cache = await refreshFizrukSqliteState(handle.client, UID);
233+
expect(cache.dailyLog).toHaveLength(1);
234+
expect(cache.dailyLog[0]!.at).toBe("2026-05-01T07:00:00Z");
235+
expect(cache.dailyLog[0]!.weightKg).toBe(81.2);
236+
});
208237
});
209238

210239
describe("getCachedFizrukSqliteState", () => {

apps/web/src/modules/fizruk/lib/sqliteReader.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -373,7 +373,7 @@ export async function refreshFizrukSqliteState(
373373
[userId],
374374
),
375375
client.all<DailyLogRow>(
376-
`SELECT id, entry_at, weight_kg, sleep_hours, energy_level, mood, note
376+
`SELECT id, entry_at AS at, weight_kg, sleep_hours, energy_level, mood, note
377377
FROM fizruk_daily_log
378378
WHERE user_id = ? AND deleted_at IS NULL
379379
ORDER BY entry_at DESC, id ASC`,

0 commit comments

Comments
 (0)