diff --git a/src/lib/resume-library.test.ts b/src/lib/resume-library.test.ts index b98af5f6..76a4b524 100644 --- a/src/lib/resume-library.test.ts +++ b/src/lib/resume-library.test.ts @@ -11,7 +11,9 @@ import "fake-indexeddb/auto"; import { deleteDB } from "idb"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as storage from "./storage/index.ts"; import { DB_NAME, closeDB, saveResume } from "./storage/index.ts"; +import type { ResumeRecord } from "./storage/types.ts"; import { saveResumeToLibrary, listLibrary, @@ -91,6 +93,50 @@ describe("resume-library: save + list", () => { expect(list.map((e) => e.filename)).toEqual(["tailored.pdf", "general.pdf"]); expect(list[0]).toMatchObject({ scoreOverall: 84, sourceKind: "pdf", hasCachedParse: true }); }); + + it("breaks ties deterministically on same-millisecond savedAt", async () => { + const tiedSavedAt = 1_700_000_000_000; + const recordA: ResumeRecord = { + id: "id-a", + filename: "a.pdf", + blob: new Blob([bytes()]), + parse: { result: result(), score: score(80), sourceKind: "pdf", shapeVersion: "1:1" }, + createdAt: tiedSavedAt, + updatedAt: tiedSavedAt, + }; + const recordB: ResumeRecord = { + id: "id-b", + filename: "b.pdf", + blob: new Blob([bytes()]), + parse: { result: result(), score: score(70), sourceKind: "pdf", shapeVersion: "1:1" }, + createdAt: tiedSavedAt, + updatedAt: tiedSavedAt, + }; + + // Return the tied records in reverse primary-key order ("id-b" before "id-a") + // to prove that listLibrary's explicit tiebreaker overrides the underlying + // store's return order rather than merely agreeing with it by coincidence (#907). + vi.spyOn(storage, "getAllResumes").mockResolvedValue([recordB, recordA]); + try { + const list = await listLibrary(); + expect(list).toHaveLength(2); + expect(list.map((e) => e.id)).toEqual(["id-a", "id-b"]); + } finally { + vi.restoreAllMocks(); + } + }); + + it("preserves newest-first save order when saves occur in the same clock millisecond", async () => { + vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000); + try { + await save("first.pdf", 70); + await save("second.pdf", 80); + const list = await listLibrary(); + expect(list.map((e) => e.filename)).toEqual(["second.pdf", "first.pdf"]); + } finally { + vi.restoreAllMocks(); + } + }); }); describe("resume-library: load", () => { diff --git a/src/lib/resume-library.ts b/src/lib/resume-library.ts index e2de3951..4859e019 100644 --- a/src/lib/resume-library.ts +++ b/src/lib/resume-library.ts @@ -209,7 +209,7 @@ export async function listLibrary(): Promise { hasCachedParse: snap !== null, }; }) - .sort((a, b) => b.savedAt - a.savedAt); + .sort((a, b) => b.savedAt - a.savedAt || a.id.localeCompare(b.id)); } /** diff --git a/src/lib/storage/crud.ts b/src/lib/storage/crud.ts index 11625130..a1c580f1 100644 --- a/src/lib/storage/crud.ts +++ b/src/lib/storage/crud.ts @@ -169,6 +169,18 @@ export async function putRecordIntoExisting( return putRecordVia(getExistingDB, store, record, options); } +let lastTimestamp = 0; + +function monotonicNow(): number { + const now = Date.now(); + if (now <= lastTimestamp) { + lastTimestamp += 1; + return lastTimestamp; + } + lastTimestamp = now; + return now; +} + async function putRecordVia( opener: () => Promise>, store: StoreName, @@ -177,7 +189,7 @@ async function putRecordVia( options: { touch?: boolean } = {}, ): Promise { const db = await looseDB(opener); - const now = Date.now(); + const now = monotonicNow(); const existing = (await db.get(store, record.id)) as T | undefined; const written = { ...record, @@ -325,7 +337,7 @@ export async function softDeleteRecord( const db = await looseDB(); const existing = (await db.get(store, id)) as StoredRecord | undefined; if (existing === undefined || !isLive(existing)) return false; - const now = Date.now(); + const now = monotonicNow(); await db.put(store, { ...existing, deletedAt: now, updatedAt: now }); emitChange(store); return true; diff --git a/src/lib/storage/storage.test.ts b/src/lib/storage/storage.test.ts index 757e8531..c81e2d41 100644 --- a/src/lib/storage/storage.test.ts +++ b/src/lib/storage/storage.test.ts @@ -10,7 +10,7 @@ import "fake-indexeddb/auto"; import { deleteDB } from "idb"; -import { beforeEach, describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { DB_NAME, getDB, closeDB } from "./db.ts"; import { saveResume, @@ -19,7 +19,8 @@ import { deleteResume, listResumeChoices, } from "./resumes.ts"; -import { saveJob, getAllJobs } from "./jobs.ts"; +import { saveJob, getAllJobs, deleteJob } from "./jobs.ts"; +import { getRecord } from "./crud.ts"; import { exportAll, exportToJson, importAll, importFromJson } from "./backup.ts"; import { captureJob } from "./capture.ts"; import { requestStoragePersistence, isStoragePersisted } from "./persist.ts"; @@ -123,6 +124,23 @@ describe("storage: jobs CRUD", () => { expect(job.title).toBe("SWE"); expect(await getAllJobs()).toHaveLength(1); }); + + it("monotonicNow guarantees softDeleteRecord timestamp is strictly greater than preceding writes in the same millisecond", async () => { + vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000); + try { + const job1 = await saveJob({ title: "SWE 1" }); + const job2 = await saveJob({ title: "SWE 2" }); + expect(job2.updatedAt).toBeGreaterThan(job1.updatedAt); + + await deleteJob(job1.id); + + const record = await getRecord("jobs", job1.id); + expect(record?.deletedAt).toBeDefined(); + expect(record!.updatedAt).toBeGreaterThan(job2.updatedAt); + } finally { + vi.restoreAllMocks(); + } + }); }); describe("storage: export / import", () => {