commit b4a06f8c37128d6ca8657454b74fb884ee98e7d2
parent a37d3329a0342ef80938fdd45ea68d798f2648e8
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 1 Oct 2026 17:39:23 -0400
common: compose never ships a posts tree the index build withheld, and trusts its cache only over its own site's last compose
- The posts tree is reconciled over the members the site's posts manifest
lists (the index build's word), so a channel config compose cannot read is
not read as visible.
- The per-site compose cache is trusted only when public/site.json names this
site (public/ is shared by every site in the basic build): after another
site's compose, a skipped stage shipped that site's summaries under this
site's name. site.json is cleared at the start of a compose and written at
its end.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
2 files changed, 74 insertions(+), 4 deletions(-)
diff --git a/common/bin/compose-site.postsVisibility.test.ts b/common/bin/compose-site.postsVisibility.test.ts
@@ -241,12 +241,40 @@ test("X private: a public site carries no X channel; a private site carries all
/^Site "priv" is private \(audience: private\)/,
);
- // Compose the public site again over the private one's public/: the X
- // channel it carried is pruned from every tree.
+ // Compose the public site again over the private one's public/, with
+ // nothing changed since its last compose: the X channel the private site
+ // carried is pruned from every tree, and the summaries are the public
+ // site's again — its compose cache is not trusted over another site's
+ // compose (the summaries used to be skipped as "unchanged" and shipped the
+ // private site's channel list).
const again = await compose("pub");
assert.deepEqual(again.postTrees, [SKY]);
assert.deepEqual(again.transcriptTrees, [VIDEOS, SKY]);
+ assert.deepEqual(slugs(again.siteJson.channels), [SKY, VIDEOS]);
+ assert.deepEqual(slugs(again.corpus.channels), [SKY, VIDEOS]);
assert.equal(again.corpus.site.audience, undefined);
+ // And over its own last compose the cache is trusted as before.
+ const third = await compose("pub");
+ assert.deepEqual(slugs(third.corpus.channels), [SKY, VIDEOS]);
+});
+
+test("a config compose cannot read does not ship the posts tree the index build withheld", async () => {
+ // The index build (X private) withheld X from pub's posts manifest; compose
+ // then fails to read X's config, so its own rule reads X as visible. The
+ // site posts manifest is the index build's word: no X posts tree ships.
+ writeSettings("private");
+ await index();
+ const cfg = path.join(paths.channelsDir, X, "config.json");
+ const saved = readFileSync(cfg, "utf8");
+ rmSync(cfg);
+ try {
+ const pub = await compose("pub");
+ assert.deepEqual(pub.postTrees, [SKY]);
+ assert.deepEqual(slugs(pub.postsManifest.channels), [SKY]);
+ assert.deepEqual(slugs(pub.corpus.channels), [SKY, VIDEOS]);
+ } finally {
+ writeFileSync(cfg, saved);
+ }
});
test("X public again: the next build puts X back on the public site", async () => {
diff --git a/common/bin/compose-site.ts b/common/bin/compose-site.ts
@@ -61,6 +61,7 @@ import {
type ArchiveManifestEntry,
} from "../lib/archiveOptions";
import { readChannelConfig } from "../controller/channels";
+import { builtSiteIdIn } from "../lib/builtExport";
import { publishedMemberSlugs } from "../lib/postsVisibility";
import { isPrivateSite } from "../lib/siteSchema";
import { runIfEntryPoint } from "./_cli";
@@ -494,6 +495,17 @@ async function replaceDir(src: string, dest: string): Promise<void> {
}
}
+// The channel slugs a site posts manifest lists, or null when there is no
+// readable manifest.
+async function readPostsManifestSlugs(file: string): Promise<Set<string> | null> {
+ try {
+ const pm = JSON.parse(await readFile(file, "utf8")) as PostsManifest;
+ return new Set((pm.channels ?? []).map((c) => c.slug));
+ } catch {
+ return null;
+ }
+}
+
// --- Incremental compose cache ------------------------------------------------
// Per-site record of what we last materialized into public/, keyed by a cheap
// content signature of each source. When the signature is unchanged and the
@@ -698,8 +710,29 @@ export async function main(
}
// Incremental compose: skip stages whose source is unchanged since last build.
+ //
+ // ONLY OVER THIS SITE'S OWN LAST COMPOSE. The cache is per site but public/
+ // is one directory every site composes into in turn (the basic build), so
+ // after another site's compose a skipped stage would ship THAT site's files —
+ // its summaries, its whole channel list — under this site's name: composing
+ // a private site and then a public one shipped the private site's summaries
+ // as the public site's. public/site.json names the site composed into it
+ // last (it is written below, every compose); another name, or none, and the
+ // cache is not trusted.
const cachePath = composeCachePath(paths, siteId);
- const cache = await readComposeCache(cachePath);
+ const lastComposed = builtSiteIdIn(paths.exportPublicDir);
+ const cache: ComposeCache =
+ lastComposed === siteId
+ ? await readComposeCache(cachePath)
+ : { transcripts: {}, subs: {}, posts: {}, digests: {} };
+ if (lastComposed !== siteId && lastComposed !== null) {
+ console.log(
+ `[compose] public/ was last composed for "${lastComposed}": composing every stage for "${siteId}".`,
+ );
+ }
+ // Until this compose writes its own, public/ names no site: a compose cut
+ // short part-way leaves the next one nothing to trust.
+ await rm(path.join(paths.exportPublicDir, "site.json"), { force: true });
// --- per-site aggregates (whole-dir swaps), gated on the source signature ---
const summariesSrc = path.join(paths.exportSitesIndexDir, siteId, "summaries");
@@ -743,11 +776,20 @@ export async function main(
// The social-post corpus: same shared-tree shape, same incremental reconcile.
// Only social member channels have a source dir; reconcileChannelTree treats a
// missing one as "nothing to copy", so passing every member slug is correct.
+ //
+ // NEVER A TREE THE SITE'S POSTS MANIFEST DOES NOT LIST. The index build wrote
+ // that manifest by the same visibility rule as memberSlugs above, so the two
+ // agree — unless a channel's config could not be read here (it then reads as
+ // visible): the manifest is the index build's word, and a tree it withheld is
+ // not shipped on a failed read. A site with no manifest yet keeps the old rule.
+ const postsListed = await readPostsManifestSlugs(
+ path.join(paths.exportSitesIndexDir, siteId, "posts", "manifest.json"),
+ );
cache.posts = await reconcileChannelTree(
"posts",
paths.exportSharedPostsDir,
paths.exportPostsDir,
- memberSlugs,
+ postsListed ? memberSlugs.filter((slug) => postsListed.has(slug)) : memberSlugs,
cache.posts ?? {},
console.log,
);