commit 6f38f174bcda2a420c2c07374d5d4c5e98d7b78c
parent 721530d506ba681198278d641dc774b0ded3ce81
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 5 Oct 2026 03:27:31 -0400
site: patchSite, a locked read-modify-write of some keys of site.json
The site is read inside the file lock and the patch applied to it, so a
writer that owns a few keys never writes back a stale copy of the rest.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
2 files changed, 60 insertions(+), 2 deletions(-)
diff --git a/common/lib/site.ts b/common/lib/site.ts
@@ -11,7 +11,7 @@ import {
type SocialLink,
} from "./settings";
import { socialLinksForSave } from "./socialLinks";
-import { readJsonFileSync, writeJsonAtomic } from "./jsonFile-server";
+import { readJsonFileSync, withJsonFileLock, writeJsonAtomic } from "./jsonFile-server";
import {
isListedSite,
isPrivateSite,
@@ -267,6 +267,33 @@ export async function writeSite(
);
}
+// PATCH SOME KEYS OF A SITE — the writer for a surface that owns a few keys and
+// none of the others (the Reports tab's `reports`). The site is read from disk
+// INSIDE the file lock and the patch is applied to that, so a patch never
+// writes back a copy of the other keys read earlier, and two patches never
+// interleave. A function patch is given the site as it is now (to reorder a
+// list it did not read itself). Validated and written by writeSite. Null when
+// the site has no site.json: a patch never creates a site.
+export async function patchSite(
+ siteId: string,
+ patch:
+ | Partial<Omit<Site, "siteId">>
+ | ((current: Site) => Partial<Omit<Site, "siteId">>),
+ paths: Paths = getPaths(),
+): Promise<Site | null> {
+ if (!isValidSiteId(siteId)) {
+ throw new Error(`Invalid site id: ${String(siteId)}`);
+ }
+ const file = siteConfigFile(paths, siteId);
+ return withJsonFileLock(file, async () => {
+ if (!fs.existsSync(file)) return null;
+ const current = getSite(siteId, paths);
+ const changes = typeof patch === "function" ? patch(current) : patch;
+ await writeSite({ ...current, ...changes, siteId }, paths);
+ return getSite(siteId, paths);
+ });
+}
+
export async function deleteSite(
siteId: string,
paths: Paths = getPaths(),
diff --git a/common/lib/siteSchema.test.ts b/common/lib/siteSchema.test.ts
@@ -18,7 +18,7 @@ import {
siteToDisk,
type Site,
} from "./siteSchema";
-import { getSite, resolveRelatedSites, siteConfigFile, writeSite } from "./site";
+import { getSite, patchSite, resolveRelatedSites, siteConfigFile, writeSite } from "./site";
import type { Paths } from "./paths";
const HERE = path.dirname(fileURLToPath(import.meta.url));
@@ -409,3 +409,34 @@ test("writeSite → getSite round-trips, and the file holds only non-defaults",
assert.equal("publish" in disk, false);
assert.equal("reports" in disk, false);
});
+
+test("patchSite applies a patch to the site on disk now, keeps every other key, and never creates a site", async () => {
+ const paths = scratchPaths(await mkdtemp(path.join(os.tmpdir(), "site-")));
+ assert.equal(await patchSite("s", { reports: ["one"] }, paths), null);
+ assert.equal(fs.existsSync(siteConfigFile(paths, "s")), false);
+
+ const site = parseSite("s", {
+ siteTitle: "Mine",
+ archives: false,
+ publish: "cited",
+ reports: ["one", "two"],
+ });
+ await writeSite(site, paths);
+ // Another writer changes a key after this caller last read the site: the
+ // patch is applied to the file, not to the stale copy.
+ await writeSite({ ...site, siteTitle: "Renamed" }, paths);
+ const patched = await patchSite("s", { reports: ["two", "one"] }, paths);
+ assert.deepEqual(patched, { ...site, siteTitle: "Renamed", reports: ["two", "one"] });
+ assert.deepEqual(getSite("s", paths), patched);
+
+ // A function patch sees the site as it is; two patches do not interleave.
+ await Promise.all([
+ patchSite("s", (cur) => ({ reports: [...(cur.reports ?? []), "three"] }), paths),
+ patchSite("s", (cur) => ({ reports: [...(cur.reports ?? []), "four"] }), paths),
+ ]);
+ assert.deepEqual(getSite("s", paths).reports, ["two", "one", "three", "four"]);
+ // The id is the directory's, whatever a patch says.
+ await patchSite("s", { siteId: "other" } as Partial<Site>, paths);
+ assert.equal(getSite("s", paths).siteId, "s");
+ assert.equal(fs.existsSync(siteConfigFile(paths, "other")), false);
+});