From d68e6c61e9b5775c7f3454387cb22c19c79fef6d Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 19 Oct 2026 18:06:44 +0000 Subject: [PATCH] 5,000 files on a free account, and a usage meter in the editor header Anthony: "make it 5k files" and "we need to show our usage/limits somewhere in the navbar". Signed in, a workspace holds up to 5,000 files (was unlimited); signed out stays 1,000. The header gets a meter beside Sync: storage and files against the plan, amber near the limit, red over it. A click opens Usage and limits (plan, files, stored, largest file, price, balance) with Add funds, or Sign in (free) when signed out. The top-up dialog is now Add funds, since the balance pays for storage too. Co-Authored-By: Claude Opus 5.5 --- README.md | 7 ++- desktop/main.cjs | 5 +- desktop/package.json | 2 +- package.json | 2 +- site/assets/viewer.css | 9 ++++ site/assets/viewer.html | 1 + site/build.ts | 2 +- site/history-panel.ts | 6 +-- site/test/sync.spec.ts | 22 ++++++++ site/usage.ts | 108 ++++++++++++++++++++++++++++++++++++++++ site/viewer.ts | 28 +++++++++-- src/blob-store.ts | 2 + src/cli.ts | 2 +- src/sync-schema.ts | 4 +- test/blob-store.test.ts | 8 +-- 15 files changed, 189 insertions(+), 19 deletions(-) create mode 100644 site/usage.ts diff --git a/README.md b/README.md index 3e24634..2087a35 100644 --- a/README.md +++ b/README.md @@ -376,9 +376,12 @@ directly with no credentials or server proxy. Local files are uploaded only when | | Without an account | Free account | Pay as you go | | --- | --- | --- | --- | | Largest file | 4 MB | 5 GB | 5 GB | -| Number of files | 1,000 (20 MB in all) | no limit | no limit | +| Number of files | 1,000 (20 MB in all) | 5,000 | 5,000 | | Synced storage | none | 5 GB | $0.05 per GB a month over 5 GB | +The meter beside Sync in the editor's header shows storage and files against these +limits; click it for the plan, the price and the balance, and to add funds. + Signed in, a file over 4 MB is a *stored file*: it opens read-only (a PDF or image is shown whole, text as its first megabyte) and Download gives it back exactly. On the device it is kept in browser storage as it is; on the account it is end-to-end @@ -735,7 +738,7 @@ explicitly overrides a conflict. Replaced local files receive numbered `.bak-NNN backups. Browser replacements receive backups in IndexedDB, accessible through **Sync → Local backups** for download or restoration. Clearing browser site data also removes those local backups. The CLI syncs a readable workspace of up to 4 MB -per file and 20 MB in total; the browser and desktop app sync any number of files of +per file and 20 MB in total; the browser and desktop app sync up to 5,000 files of up to 5 GB each (see File limits and storage). The authenticated API exposes: diff --git a/desktop/main.cjs b/desktop/main.cjs index 93c4cba..6100cfd 100644 --- a/desktop/main.cjs +++ b/desktop/main.cjs @@ -29,6 +29,8 @@ const INLINE_BYTES = 4 * 1024 * 1024; /** The largest file a signed-in account may keep (src/blob-store.ts); the page applies its own limits again. */ const MAX_FILE_BYTES = 5 * 1024 ** 3; const MAX_RANGE = 16 * 1024 * 1024; +/** A signed-in workspace's file count (src/blob-store.ts); the walk stops there. */ +const MAX_FILES = 5000; const IGNORED = new Set(["node_modules", "dist", "build", "out", "target", "vendor", "coverage", "venv", "__pycache__", ".git"]); const NEVER = /\.(?:zip|gz|tgz|bz2|xz|7z|rar|tar|exe|dll|so|dylib|o|a|class|jar|war|wasm|bin|dat|db|sqlite|sqlite3|mp3|mp4|m4a|mov|avi|mkv|webm|ogg|wav|flac|woff2?|ttf|otf|eot|psd|ai|doc|docx|xls|xlsx|ppt|pptx|iso|dmg|pkg|deb|rpm|apk|lock)$/i; /** The bundled editor: inside the package, or the repo's site build when run with `electron .`. */ @@ -50,13 +52,14 @@ let window; async function load(paths, cwd = process.cwd()) { const files = []; const add = async (path, info, relative) => { - if (info.size > MAX_FILE_BYTES) return; + if (info.size > MAX_FILE_BYTES || files.length >= MAX_FILES) return; const data = info.size > INLINE_BYTES ? undefined : new Uint8Array(await readFile(path)); files.push({ path, name: basename(path), relative, size: info.size, data }); opened.add(path); }; const walk = async (directory, prefix) => { for (const entry of await readdir(directory, { withFileTypes: true })) { + if (files.length >= MAX_FILES) return; if (entry.name.startsWith(".") || IGNORED.has(entry.name)) continue; const path = join(directory, entry.name); const relative = `${prefix}/${entry.name}`; diff --git a/desktop/package.json b/desktop/package.json index ddf58a2..9b9c265 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "readm3-desktop", "productName": "readm3", - "version": "0.16.0", + "version": "0.17.0", "private": true, "description": "readm3 as a desktop app: a plain, fast text editor for Markdown and code.", "author": "Profullstack, Inc. ", diff --git a/package.json b/package.json index f696b20..411ab5d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/readm3", - "version": "0.16.0", + "version": "0.17.0", "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/assets/viewer.css b/site/assets/viewer.css index 92182c0..9aeda1d 100644 --- a/site/assets/viewer.css +++ b/site/assets/viewer.css @@ -116,6 +116,15 @@ html, body.reader-app { height: 100%; overflow: hidden; } .media-view img { max-width: 100%; height: auto; border: 1px solid var(--border); border-radius: 6px; background: repeating-conic-gradient(color-mix(in srgb, var(--fg) 6%, transparent) 0 25%, transparent 0 50%) 0 0 / 20px 20px; } .media-note { color: var(--muted); font: 11px var(--mono); } .media-note a { color: var(--primary); } +.usage { display: inline-flex; align-items: center; gap: 8px; font: 11px var(--mono); color: var(--muted); white-space: nowrap; } +.usage-bar { width: 44px; height: 6px; border-radius: 3px; background: var(--border); overflow: hidden; flex: none; } +.usage-fill { display: block; height: 100%; width: 0; background: var(--success); } +.usage.near .usage-fill { background: var(--warning, #c98a00); } +.usage.over .usage-fill { background: var(--danger, #c0392b); } +.usage-table { display: grid; grid-template-columns: auto 1fr; gap: 6px 18px; margin: 14px 0 12px; } +.usage-table dt { color: var(--muted); } +.usage-table dd { margin: 0; font-family: var(--mono); } +@media (max-width: 1100px) { .usage-text { display: none; } } .large-preview { margin: 12px 0 0; font: 13px/1.5 var(--mono); white-space: pre-wrap; overflow-wrap: anywhere; } .reader-app.dragging::after { content: "Drop files here"; position: fixed; inset: 12px; display: grid; place-items: center; background: color-mix(in srgb, var(--bg) 95%, transparent); border: 2px dashed var(--primary); border-radius: 12px; color: var(--primary); font: 20px var(--mono); pointer-events: none; z-index: 10; } [data-theme="light"] { color-scheme: light; } diff --git a/site/assets/viewer.html b/site/assets/viewer.html index da37124..1ddbb5d 100644 --- a/site/assets/viewer.html +++ b/site/assets/viewer.html @@ -21,6 +21,7 @@ readm3 / editor
Workspaces + Browser workspace diff --git a/site/build.ts b/site/build.ts index 2e2cd11..19ec3b6 100644 --- a/site/build.ts +++ b/site/build.ts @@ -255,7 +255,7 @@ function home(): string {

Keep the story of the document.

Every online save creates a version. See who changed it, share a specific version, or restore an earlier draft without losing the history.

A workspace for your team.

Organize people into organizations and teams. Share with a whole group, a few collaborators, or anyone with your link.

A plain text editor.

Line numbers, multiple cursors, find and replace, folding, Ctrl+P to any file and Ctrl+Shift+P for every command. Open a folder from disk and Ctrl+S saves back to it. Open the editor.

-

Files up to 5 GB, as many as you like.

A free account lifts the limits: any number of files, each up to 5 GB, with 5 GB of end-to-end encrypted storage that syncs to every device. Need more? Pay as you go, $0.05 per GB a month, from a CoinPay balance.

+

Files up to 5 GB, 5,000 of them.

A free account lifts the limits: up to 5,000 files, each up to 5 GB, with 5 GB of end-to-end encrypted storage that syncs to every device. Need more? Pay as you go, $0.05 per GB a month, from a CoinPay balance.

Take the editor with you.

Install readm3 from your browser, or get the desktop app: Linux AppImage or Flatpak, macOS for Apple silicon or Intel, Windows. Then readm3 edit . opens a folder from the terminal, like subl .. Local files work offline; shared documents stay connected.

Use the tools you already use.

The web editor, CLI, API, and MCP work with the same documents and permissions. Your terminal and your agents can join the workflow.

diff --git a/site/history-panel.ts b/site/history-panel.ts index fd68bcf..d3feda6 100644 --- a/site/history-panel.ts +++ b/site/history-panel.ts @@ -113,10 +113,10 @@ export async function showTopUp(): Promise { catch { location.href = `/account?next=${encodeURIComponent(location.pathname + location.search)}`; return; } const box = document.createElement("dialog"); box.className = "history-dialog topup-dialog"; - box.setAttribute("aria-label", "Top up AI summaries"); + box.setAttribute("aria-label", "Add funds"); const per = billing.multiplier === 2 ? "twice" : `${billing.multiplier} times`; - box.innerHTML = `

Top up AI summaries

-

Pay as you go: each summary costs ${per} what the AI call costs us, usually a fraction of a cent. Your balance never expires.

+ box.innerHTML = `

Add funds

+

Pay as you go, from one balance that never expires: storage over the free 5 GB is $0.05 per GB a month, and each AI summary costs ${per} what the AI call costs us, usually a fraction of a cent.

`; diff --git a/site/test/sync.spec.ts b/site/test/sync.spec.ts index faefde3..8229eec 100644 --- a/site/test/sync.spec.ts +++ b/site/test/sync.spec.ts @@ -151,6 +151,28 @@ test("a signed-in account opens a file over 4 MB read-only and syncs it as an en expect((await stat((await (await downloading).path())!)).size).toBe(big.length); }); +test("the header shows usage against the plan's limits, signed out and signed in", async ({ page, context }) => { + await page.goto("/viewer"); + const usage = page.locator("#usage"); + await expect(usage).toBeVisible(); + await expect(usage).toHaveAttribute("title", /\/ 20\.0 MB · \d+ \/ 1,000 files/); + await usage.click(); + const dialog = page.getByRole("dialog", { name: "Usage and limits" }); + await expect(dialog).toContainText("No account"); + await expect(dialog.getByRole("link", { name: "Sign in (free)" })).toBeVisible(); + await dialog.getByRole("button", { name: "Close dialog" }).click(); + + await register(context, username()); + await page.reload(); + await setPassphrase(page); + await expect(usage).toHaveAttribute("title", /\/ 5\.0 GB · \d+ \/ 5,000 files/); + await usage.click(); + await expect(dialog).toContainText("Free account"); + await expect(dialog).toContainText("$0.05 per GB a month"); + await expect(dialog).toContainText("Balance"); + await expect(dialog.getByRole("button", { name: "Add funds" })).toBeVisible(); +}); + test("signed out, a file over 4 MB asks for a free account", async ({ page }) => { await page.goto("/viewer"); await page.locator("#files-input").setInputFiles({ name: "huge.md", mimeType: "text/markdown", buffer: Buffer.alloc(4 * 1024 * 1024 + 1, "a") }); diff --git a/site/usage.ts b/site/usage.ts new file mode 100644 index 0000000..b4bea41 --- /dev/null +++ b/site/usage.ts @@ -0,0 +1,108 @@ +/** + * The usage meter in the editor's header: files and storage against the plan's limits. + * Signed out it measures this device's workspace (1,000 files, 20 MB); signed in it + * measures the account (5,000 files, 5 GB stored free, then pay as you go). A click + * opens the limits, the price, the balance and a way to add funds or sign in. + */ +import { modal } from "./cloud.ts"; +import * as stored from "./blobs.ts"; +import { showTopUp } from "./history-panel.ts"; +import { formatStorage, FREE_STORAGE_BYTES, MAX_ACCOUNT_FILES, MAX_BLOB_BYTES, STORAGE_MICROS_PER_GIB_MONTH } from "../src/blob-store.ts"; + +export const LOCAL_FILES = 1000; +export const LOCAL_BYTES = 20 * 1024 * 1024; +const LOCAL_FILE_BYTES = 4 * 1024 * 1024; + +export interface LocalUsage { files: number; bytes: number; signedIn: boolean } + +let account: stored.StorageAccount | null = null; +let fetchedAt = 0; +let last: LocalUsage = { files: 0, bytes: 0, signedIn: false }; + +const dollars = (micros: number) => `$${(micros / 1e6).toFixed(2)}`; +const count = (n: number) => n.toLocaleString(); + +/** Redraws the meter; the account's numbers are fetched at most every 30 s unless forced. */ +export async function refreshUsage(local: LocalUsage, force = false) { + last = local; + if (!local.signedIn) account = null; + else if (force || !account || Date.now() - fetchedAt > 30_000) { + fetchedAt = Date.now(); + try { account = await stored.account(); } catch { /* Offline: the last numbers stand. */ } + } + paint(); +} + +function numbers() { + if (last.signedIn) { + const used = account?.usedBytes ?? 0; + const free = account?.freeBytes ?? FREE_STORAGE_BYTES; + return { files: last.files, fileLimit: MAX_ACCOUNT_FILES, used, limit: free, over: used > free }; + } + return { files: last.files, fileLimit: LOCAL_FILES, used: last.bytes, limit: LOCAL_BYTES, over: false }; +} + +function paint() { + const button = document.getElementById("usage"); + if (!button) return; + const n = numbers(); + const share = Math.min(1, Math.max(n.used / n.limit, n.files / n.fileLimit)); + const text = `${formatStorage(n.used)} / ${formatStorage(n.limit)} · ${count(n.files)} / ${count(n.fileLimit)} files`; + button.hidden = false; + button.classList.toggle("near", share >= 0.8 && !n.over); + button.classList.toggle("over", n.over || share >= 1); + button.title = n.over ? `${text} · over the free storage, pay as you go` : text; + button.setAttribute("aria-label", `Usage and limits: ${button.title}`); + const bar = button.querySelector(".usage-fill"); + // Anything in use shows at least a sliver, so an empty bar means an empty workspace. + if (bar) bar.style.width = `${share > 0 ? Math.max(4, Math.round(share * 100)) : 0}%`; + const label = button.querySelector(".usage-text"); + if (label) label.textContent = n.over ? `${formatStorage(n.used)} · pay as you go` : text; +} + +export function showUsage() { + const n = numbers(); + const box = modal("Usage and limits"); + const rows: [string, string][] = last.signedIn ? [ + ["Plan", n.over || (account?.balanceMicros ?? 0) > 0 ? "Free account, pay as you go" : "Free account"], + ["Files", `${count(n.files)} of ${count(n.fileLimit)}`], + ["Stored files", `${formatStorage(n.used)} of ${formatStorage(n.limit)} free`], + ["Largest file", formatStorage(account?.maxFileBytes ?? MAX_BLOB_BYTES)], + ["Over the free storage", `${dollars(account?.microsPerGibMonth ?? STORAGE_MICROS_PER_GIB_MONTH)} per GB a month`], + ["Balance", account ? dollars(account.balanceMicros) : "unavailable offline"], + ] : [ + ["Plan", "No account: this device only"], + ["Files", `${count(n.files)} of ${count(n.fileLimit)}`], + ["Workspace", `${formatStorage(n.used)} of ${formatStorage(n.limit)}`], + ["Largest file", formatStorage(LOCAL_FILE_BYTES)], + ]; + const table = document.createElement("dl"); + table.className = "usage-table"; + for (const [term, value] of rows) { + const dt = document.createElement("dt"); + dt.textContent = term; + const dd = document.createElement("dd"); + dd.textContent = value; + table.append(dt, dd); + } + const note = document.createElement("p"); + note.textContent = last.signedIn + ? "Files over 4 MB are stored end-to-end encrypted on your account. Storage over the free allowance is charged hourly from your balance; with no balance, new uploads past it wait, and nothing stored is deleted." + : "A free account raises the limits to 5,000 files of up to 5 GB each, with 5 GB of encrypted storage that syncs to every device."; + const actions = document.createElement("p"); + if (last.signedIn) { + const add = document.createElement("button"); + add.type = "button"; + add.className = "primary"; + add.textContent = "Add funds"; + add.onclick = () => { box.close(); void showTopUp(); }; + actions.append(add); + } else { + const link = document.createElement("a"); + link.className = "primary"; + link.href = `/account?next=${encodeURIComponent("/viewer")}`; + link.textContent = "Sign in (free)"; + actions.append(link); + } + box.body.append(table, note, actions); +} diff --git a/site/viewer.ts b/site/viewer.ts index 97b4d29..94badb5 100644 --- a/site/viewer.ts +++ b/site/viewer.ts @@ -1,6 +1,7 @@ import { action, request, type CloudDocument, type CloudUser } from "./cloud.ts"; import * as stored from "./blobs.ts"; -import { formatStorage, INLINE_MAX_BYTES, MAX_BLOB_BYTES, newBlobRef } from "../src/blob-store.ts"; +import { refreshUsage, showUsage } from "./usage.ts"; +import { formatStorage, INLINE_MAX_BYTES, MAX_ACCOUNT_FILES, MAX_BLOB_BYTES, newBlobRef } from "../src/blob-store.ts"; import { pasteDialog, publish, sharing, history as versionHistory } from "./sharing.ts"; import { showSync } from "./sync.ts"; import { askPassphrase, autoSyncEnabled, forgetPassphrase, schedule as scheduleSync, setAutoSync, startAutoSync, syncNow } from "./autosync.ts"; @@ -66,7 +67,7 @@ const isMarkdown = () => kind().kind === "markdown"; * signed out, the workspace keeps its small local limits. */ let signedIn = false; -const ACCOUNT_LIMITS = "Sign in (free) for files up to 5 GB and no limit on the number of files."; +const ACCOUNT_LIMITS = "Sign in (free) for files up to 5 GB and up to 5,000 files."; /** A stored file too large for the workspace (not a spilled small one): shown read-only. */ const isLarge = (doc = current()) => !!doc.blob && !doc.blob.digest; const PREVIEW_BYTES = 1024 * 1024; @@ -188,6 +189,7 @@ async function save() { const unsaved = unsavedOnDisk(current()); saveStatus.textContent = unsaved ? "Saved on this device · Ctrl+S writes it to disk" : diskEntries.has(current().path) ? "Saved to disk" : "Saved on this device"; markUnsaved(); + updateUsage(); } } catch { saveFailed = true; @@ -301,7 +303,8 @@ function commands(): PaletteItem[] { command("File: Save As…", () => void saveFile(true), "Ctrl+Shift+S"), command("File: Close tab", () => closeTab(workspace.active)), command("File: History", openHistory), - command("Billing: Top up AI summaries", () => void showTopUp()), + command("Billing: Add funds", () => void showTopUp()), + command("Billing: Usage and limits", showUsage), command("Sync: Sync this workspace to your account", () => element("sync-workspace").click()), ] : [], ...cloudDocument?.canEdit ? [command("File: Save version", () => void saveCloud(), "Ctrl+S")] : [], @@ -608,6 +611,10 @@ function addDocuments(incoming: Document[], name?: string): Document[] { notice(signedIn ? "A file over 4 MB could not be stored apart. Try opening it again." : `Workspace limit reached: 4 MB per file, 20 MB total, and 1,000 files without an account. ${ACCOUNT_LIMITS}`); break; } + if (signedIn && map.size >= MAX_ACCOUNT_FILES && !map.has(doc.path)) { + notice(`Workspace limit reached: ${MAX_ACCOUNT_FILES.toLocaleString()} files. ${incoming.length - added.length} file(s) were not added.`); + break; + } // Reopening a file refreshes the copy that matches disk (`readm3 edit .` every morning), // but must never silently replace an edited draft: that one keeps its place. let path = doc.path; @@ -645,9 +652,12 @@ async function importFiles(files: disk.Picked[], name = "Your workspace") { let bytes = 0; let skipped = 0; let needAccount = 0; + let overCount = 0; let unreadable = 0; for (const { file, path, handle } of files) { if (!accepts(path)) continue; + // Past the account's file count nothing more is read. + if (signedIn && incoming.length >= MAX_ACCOUNT_FILES) { overCount++; continue; } // A PDF or image is kept as base64, a third larger than the file; the limits apply to what is stored. const cost = binaryType(path) ? Math.ceil(file.size * 4 / 3) : file.size; if (cost > MAX_FILE_BYTES) { @@ -693,6 +703,7 @@ async function importFiles(files: disk.Picked[], name = "Your workspace") { else if (!needAccount) notice(`No readable files found. Open text files (Markdown, JSON, code, config), PDFs or images up to ${signedIn ? "5 GB" : "4 MB"}.`); if (needAccount) notice(`${needAccount} file(s) skipped: over 4 MB, or past 20 MB and 1,000 files, without an account. ${ACCOUNT_LIMITS}`); if (skipped) notice(`${skipped} file(s) skipped: larger than 5 GB.`); + if (overCount) notice(`${overCount} file(s) skipped: a workspace holds up to ${MAX_ACCOUNT_FILES.toLocaleString()} files.`); if (unreadable) notice(`${unreadable} file(s) skipped: not text, PDF or image.`); } catch (error) { notice(error instanceof DOMException && error.name === "QuotaExceededError" @@ -1119,6 +1130,14 @@ async function setupPwa() { async function refreshAccount() { try { signedIn = !!(await request<{ user: CloudUser | null }>("me")).user; } catch { /* Offline: the last answer stands. */ } + updateUsage(true); +} + +/** The header meter: this workspace's files and storage against the plan (site/usage.ts). */ +function updateUsage(force = false) { + if (!workspace || cloudDocument || pasteDocument) return; + const bytes = workspace.documents.reduce((sum, doc) => sum + doc.source.length, 0); + void refreshUsage({ files: workspace.documents.length, bytes, signedIn }, force); } async function start() { @@ -1174,6 +1193,7 @@ async function start() { bindEvents(); await save(); // The account decides the limits before anything is opened from a link or the desktop. + element("usage").onclick = showUsage; await refreshAccount(); window.addEventListener("focus", () => void refreshAccount()); const params = new URLSearchParams(location.search); @@ -1191,7 +1211,7 @@ async function start() { read: () => ({ name: workspace.name, active: workspace.active, documents: workspace.documents }), settings: () => ({ theme: theme.value, flavor: flavor.value as Flavor }), apply: (incoming, settings) => applySynced(incoming as Workspace, settings), - status: (text) => { element("sync-note").textContent = text; }, + status: (text) => { element("sync-note").textContent = text; if (text.startsWith("Synced")) updateUsage(true); }, device: disk.desktop ? "desktop" : "browser", seedUrl: __SEED_URL__, app: "readm3 web", diff --git a/src/blob-store.ts b/src/blob-store.ts index bd71484..38479c8 100644 --- a/src/blob-store.ts +++ b/src/blob-store.ts @@ -20,6 +20,8 @@ export const GiB = 1024 ** 3; /** The largest single file an account may keep. */ export const MAX_BLOB_BYTES = 5 * GiB; +/** Files in one signed-in workspace (signed out, the workspace keeps 1,000). */ +export const MAX_ACCOUNT_FILES = 5000; /** Storage every signed-in account gets free; more is pay as you go from the prepaid balance. */ export const FREE_STORAGE_BYTES = 5 * GiB; /** Pay as you go: micro-dollars per GiB stored per month, over the free allowance. */ diff --git a/src/cli.ts b/src/cli.ts index ccd0434..127dcf5 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.16.0"; +export const VERSION = "0.17.0"; /** 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 diff --git a/src/sync-schema.ts b/src/sync-schema.ts index cad9995..3be5095 100644 --- a/src/sync-schema.ts +++ b/src/sync-schema.ts @@ -1,7 +1,7 @@ /** Browser-safe account data contract. Only these two files may be synced. */ import { isFlavor, type Flavor } from "./flavors.ts"; import { envelopeProblem, SEALED_FILE } from "./sync-crypto.ts"; -import { blobRefProblem, INLINE_MAX_BYTES, SNAPSHOT_INLINE_BYTES, type BlobRef } from "./blob-store.ts"; +import { blobRefProblem, INLINE_MAX_BYTES, MAX_ACCOUNT_FILES, SNAPSHOT_INLINE_BYTES, type BlobRef } from "./blob-store.ts"; /** * A workspace file. `source` is the text, or base64 bytes when the path names a PDF or @@ -33,7 +33,7 @@ export function validateWorkspace(value: unknown): SyncedWorkspace { if (!value || typeof value !== "object") throw new Error("Workspace must be an object."); const ws = value as SyncedWorkspace; if (Object.keys(ws).some((key) => !["name", "active", "documents"].includes(key))) throw new Error("Workspace can only contain name, active and documents."); - if (typeof ws.name !== "string" || ws.name.length > 200 || !Array.isArray(ws.documents) || !ws.documents.length) throw new Error("Workspace needs a name and at least one file."); + if (typeof ws.name !== "string" || ws.name.length > 200 || !Array.isArray(ws.documents) || !ws.documents.length || ws.documents.length > MAX_ACCOUNT_FILES) throw new Error("Workspace needs a name and 1–5,000 files."); const paths = new Set(); let total = 0; const encoder = new TextEncoder(); diff --git a/test/blob-store.test.ts b/test/blob-store.test.ts index a6fd120..66725b7 100644 --- a/test/blob-store.test.ts +++ b/test/blob-store.test.ts @@ -28,10 +28,12 @@ test("chunk arithmetic covers a 5 GB file exactly", () => { assert.match(blobRefProblem({ ...newBlobRef(1), id: "../../etc" })!, /invalid id/); }); -test("a synced workspace has no file count limit and carries stored files by reference", () => { - const documents = Array.from({ length: 5000 }, (_, i) => ({ path: `notes/${i}.md`, source: `# ${i}` })); +test("a synced workspace holds up to 5,000 files and carries stored files by reference", () => { + const documents = Array.from({ length: 4999 }, (_, i) => ({ path: `notes/${i}.md`, source: `# ${i}` })); documents.push({ path: "big/dump.json", source: "", blob: newBlobRef(3 * 1024 ** 3) } as never); - assert.equal(validateWorkspace({ name: "Many", active: "notes/0.md", documents }).documents.length, 5001); + assert.equal(validateWorkspace({ name: "Many", active: "notes/0.md", documents }).documents.length, 5000); + documents.push({ path: "one/more.md", source: "" }); + assert.throws(() => validateWorkspace({ name: "Many", active: "notes/0.md", documents }), /1–5,000 files/); assert.throws(() => validateWorkspace({ name: "x", active: "a.md", documents: [{ path: "a.md", source: "text", blob: newBlobRef(1) }] }), /source must be empty/); });