From 24642b19d072684263bd93adaf836e8e6b6b2491 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 28 Oct 2026 13:29:52 +0000 Subject: [PATCH] History: the newest save is the top version (v0.26.1) Anthony: "most recent save should be tip of history". The first synced save of a file with no account history also files the text it had before; that version was sealed undated and took the server's clock, a moment after the save, so the old text sorted above the save (e.g. list.txt saved in Sublime). - the text before is now dated just ahead of the save it precedes. - versions already filed undated are listed as the oldest, where they belong. - sync.spec: a file synced with no history, then edited: the edit is the top version on the device that saved it (fails on main). Co-Authored-By: Claude Opus 5.5 --- package.json | 2 +- site/cloud-history.ts | 11 ++++++++--- site/test/sync.spec.ts | 14 +++++++++++++- src/cli.ts | 2 +- 4 files changed, 23 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index fee1c00..ab258b9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/readm3", - "version": "0.26.0", + "version": "0.26.1", "description": "Markdown editing and sharing for people and teams. Web/PWA, CLI, API and MCP with version history, view/edit links, and a terminal reader.", "type": "module", "license": "MIT", diff --git a/site/cloud-history.ts b/site/cloud-history.ts index 71aa07a..841571c 100644 --- a/site/cloud-history.ts +++ b/site/cloud-history.ts @@ -24,7 +24,8 @@ export async function record(changes: Change[], key: SyncKey, device: string): P const sealed = async (source: Pick, when: string, ifEmpty?: boolean) => ({ tag, ifEmpty, sealed: JSON.stringify(await seal({ path: doc.path, source: source.source, language: source.language, device, at: when } satisfies Sealed, key)), }); - if (before && !before.blob && before.source !== doc.source) versions.push(await sealed(before, "", true)); + // The text before is dated just ahead of the save, so the save stays the newest version. + if (before && !before.blob && before.source !== doc.source) versions.push(await sealed(before, new Date(Date.parse(at) - 1).toISOString(), true)); versions.push(await sealed(doc, at)); } for (let i = 0; i < versions.length; i += 50) await request("file-history", "POST", { versions: versions.slice(i, i + 50) }); @@ -50,9 +51,13 @@ export async function list(path: string, limit = 25): Promise { const version = await open(JSON.parse(sealed) as Envelope, key); // A version sealed for another path under the same tag cannot happen, but is never shown. if (version.path !== path) return null; - return { id, at: version.at || createdAt, device: version.device, source: version.source, ...(version.language ? { language: version.language } : {}) }; + return { id, at: version.at, createdAt, device: version.device, source: version.source, ...(version.language ? { language: version.language } : {}) }; } catch { return null; } })); - return opened.filter((version): version is CloudVersion => !!version); + const found = opened.filter((version) => !!version); + // Before 0.26.1 the text before a file's first synced save was sealed undated and took the + // server's later clock, so it sorted above that save; it is the oldest version, so it goes last. + const oldest = Math.min(...found.filter((version) => version.at).map((version) => Date.parse(version.at))); + return found.map(({ createdAt, ...version }) => ({ ...version, at: version.at || (Number.isFinite(oldest) ? new Date(oldest - 1).toISOString() : createdAt) })); } catch { return []; } } diff --git a/site/test/sync.spec.ts b/site/test/sync.spec.ts index 94c1910..733d913 100644 --- a/site/test/sync.spec.ts +++ b/site/test/sync.spec.ts @@ -1,4 +1,4 @@ -import { test, expect, type Page, type BrowserContext } from "@playwright/test"; +import { test, expect, type Page, type BrowserContext, type Route } from "@playwright/test"; import { stat } from "node:fs/promises"; import { editorInput } from "./editor.ts"; @@ -77,9 +77,14 @@ test("two devices sync files and settings automatically through the encrypted co await setPassphrase(page); await page.locator("#theme").selectOption("nord"); await page.locator("#flavor").selectOption("reddit"); + // The file reaches the account with no history yet (as files synced before history did), so the + // first edit files the text before it too, and that older text must not sort above the edit. + const noHistory = (route: Route) => route.request().method() === "POST" ? route.fulfill({ json: { saved: 0 } }) : route.continue(); + await page.route("**/api/v1/file-history", noHistory); await page.locator("#files-input").setInputFiles({ name: "Synced.md", mimeType: "text/markdown", buffer: Buffer.from("# Across devices\n\nHello 😺\n") }); await expect.poll(() => sealedSnapshot(page), { timeout: 20_000 }).not.toBeNull(); await expect(page.locator("#sync-note")).toContainText("Synced", { timeout: 20_000 }); + await page.unroute("**/api/v1/file-history", noHistory); const second = await browser.newContext(); try { @@ -99,6 +104,13 @@ test("two devices sync files and settings automatically through the encrypted co await editorInput(other).fill("# Edited on second device\n"); // "Synced" is already on screen from the first pass; wait for the edit to reach the account. await expect.poll(async () => (await sealedSnapshot(page))?.revision, { timeout: 20_000 }).toBeGreaterThan(before); + // On the device that saved it, the save is the newest version: the text before it is listed below. + await other.locator("#history").click(); + const mine = other.locator("#file-history-dialog .history-item"); + await expect(mine.locator(".history-reason", { hasText: "synced from browser" }).first()).toBeVisible({ timeout: 20_000 }); + await expect(mine.filter({ hasText: "Across devices" }).first()).toBeVisible({ timeout: 20_000 }); + await expect(mine.first().locator(".history-snippet .added").first()).toContainText("Edited on second device"); + await other.locator("#file-history-dialog").getByRole("button", { name: "Close history" }).click(); await page.evaluate(() => window.dispatchEvent(new Event("focus"))); await page.locator('.file-row[data-path="Synced.md"]').click(); diff --git a/src/cli.ts b/src/cli.ts index d53520a..cda2eed 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -12,7 +12,7 @@ import { run, type ViewerOptions } from "./viewer.ts"; import { isCloudCommand, cloudMain, CLOUD_USAGE } from "./cloud-cli.ts"; import { isDesktopCommand, desktopMain, installedDesktop, prefersDesktop, DESKTOP_USAGE } from "./desktop-cli.ts"; -export const VERSION = "0.26.0"; +export const VERSION = "0.26.1"; /** The help text, exported so a test can hold it to the flags it documents. */ export const USAGE = `readm3 — a plain text editor for Markdown and code