Context
Part C of 3 in the flat retirement split (parent: #869, grandparent: #866).
Depends on Part A (#889) — after Part A, writeWikiPage without a tenant writes to the silo, so reconcileSilos can no longer read "flat" content that differs from silo content. The forward-pass (flat→silo sync) becomes dead code.
This issue removes the forward pass from reconcileSilos, keeping only the reverse pass (ghost/orphan silo file cleanup). It also updates the maintenance.ts comment and the silo test suite.
Step-by-step Implementation Plan
Step 1: Remove the forward pass from reconcileSilos (src/lib/silo.ts)
Function: reconcileSilos (line 189)
Replace the entire function body. Keep the signature and ReconcileResult interface unchanged.
The current forward pass (lines 200–228) iterates listWikiPages(), reads from wikiRelPath(...) (flat), compares to silo, and syncs differences. After Part A, all writes go to silo, so flat content is stale/absent — this loop is dead code.
New implementation:
export async function reconcileSilos(): Promise<ReconcileResult> {
const { listWikiPages } = await import("./wiki");
const storage = getStorage();
const pages = await listWikiPages();
const result: ReconcileResult = {
total: 0,
synced: 0,
stale: 0,
alreadyCurrent: 0,
removed: 0,
errors: [],
};
// Forward pass retired — silo is now the sole write target (#869).
// Only the reverse pass (orphan cleanup) remains.
// ── Reverse pass: find silo files with no index entry (ghosts) ──
const pageSlugs = new Set(pages.map((p) => p.slug));
try {
const tenantDirs = await listSafe("tenants");
for (const td of tenantDirs) {
if (!td.isDirectory) continue;
const tenant = td.name;
let wikiPrefix: string;
try {
wikiPrefix = tenantWikiRelPath(tenant, "");
} catch {
continue; // invalid tenant dir name — skip
}
let siloFiles: Awaited<ReturnType<typeof listSafe>>;
try {
siloFiles = await listSafe(wikiPrefix);
} catch {
continue;
}
for (const f of siloFiles) {
if (f.isDirectory || !f.name.endsWith(".md")) continue;
const slug = f.name.replace(/\.md$/, "");
if (SKIP.has(slug)) continue;
if (pageSlugs.has(slug)) continue;
try {
await removeSiloForPage(slug, tenant);
result.removed++;
} catch (e) {
result.errors.push(`reverse-orphan ${tenant}/${slug}: ${String(e)}`);
logger.warn("silo", `reverse-orphan cleanup failed for "${tenant}/${slug}":`, e);
}
}
}
} catch (e) {
logger.warn("silo", "reverse-orphan scan failed:", e);
}
return result;
}
Step 2: Remove unused wikiRelPath import from src/lib/silo.ts
WAIT — check first. wikiRelPath is also used in syncSiloForPage (line ~91, 107, 111). That function still exists and is used by other code paths (manual migration, admin rebuild). So:
- Do NOT remove
wikiRelPath from silo.ts. It is still used by syncSiloForPage.
Step 3: Update maintenance.ts comment (line 274–276)
File: src/lib/maintenance.ts, lines 274–276
Replace the comment:
// BEFORE:
// Silo reconciliation runs LAST — it reads from flat (the write primary) and
// needs the page index to be fresh. Fail-soft: a reconcile failure never
// blocks the index rebuilds above.
// AFTER:
// Silo reconciliation runs LAST — it removes orphan silo files that have no
// index entry. Fail-soft: a reconcile failure never blocks the index rebuilds
// above.
Step 4: Remove forward-pass tests from src/lib/__tests__/silo.test.ts
Remove the following 6 tests from the describe("reconcileSilos", ...) block:
- "syncs pages that are missing from their tenant silo" (~line 76) — Tests the forward pass (flat→silo sync). Dead after forward pass removal.
- "skips pages that already have a silo copy" (~line 107) — Tests forward pass skip logic.
- "skips infrastructure slugs (index, log)" (~line 124) — Tests forward pass SKIP set.
- "returns a mix of synced and already-current pages" (~line 138) — Tests forward pass mixed results.
- "uses DEFAULT_TENANT for pages without an owner" (~line 160) — Tests forward pass tenant resolution.
- "detects and re-syncs stale silo content" (~line 177) — Tests the stale detection in the forward pass.
Keep the "removes ghost silo files that have no index entry" test (~line 218). But update it: the test currently calls writeWikiPage (which after Part A writes to silo), then syncSiloForPage. Since the forward pass is gone, plant the real page's silo file directly:
it("removes ghost silo files that have no index entry", async () => {
const storage = getStorage();
// A real page: in the index and has a silo file.
await storage.writeFile(
"tenants/alice/wiki/real.md",
"---\nowner: alice\n---\n# Real\n\nContent.",
);
await updateIndex([{ slug: "real", title: "Real", summary: "Content" }]);
// A ghost: silo file exists but NO index entry.
await storage.writeFile("tenants/alice/wiki/ghost.md", "# Ghost");
const result = await reconcileSilos();
expect(result.removed).toBe(1);
expect(await storage.fileExists("tenants/alice/wiki/ghost.md")).toBe(false);
// Real page's silo is untouched.
expect(await storage.fileExists("tenants/alice/wiki/real.md")).toBe(true);
});
Step 5: Remove unused imports from silo.test.ts
After removing the forward-pass tests, check whether syncSiloForPage is still used in the remaining reconcileSilos tests. If NOT, and if syncSiloForPage tests in the same file still import it, leave it. But if writeWikiPage is no longer used anywhere in the file, remove it from the import.
Check the syncSiloForPage describe block — those tests still need writeWikiPage to set up flat content for syncing. So writeWikiPage likely stays.
Gotchas
- Do NOT remove
wikiRelPath from silo.ts — syncSiloForPage still uses it.
- Do NOT remove
syncSiloForPage — it is still called by admin/migration routes.
ReconcileResult interface shape must stay unchanged — synced, stale, and alreadyCurrent will always be 0, but the shape is part of the API contract.
- The
listWikiPages dynamic import (await import("./wiki")) stays — the reverse pass needs the page slug set.
- The
SKIP constant stays — the reverse pass uses it to skip infrastructure slugs.
Acceptance Criteria
Size Estimate
Medium — 3 files (silo.ts, silo.test.ts, maintenance.ts), ~50 lines deleted, ~10 lines added
Dependencies
Blocked-By: #889
Blocker-Type: dependency
Unblock-To: ready
Context
Part C of 3 in the flat retirement split (parent: #869, grandparent: #866).
Depends on Part A (#889) — after Part A,
writeWikiPagewithout a tenant writes to the silo, soreconcileSiloscan no longer read "flat" content that differs from silo content. The forward-pass (flat→silo sync) becomes dead code.This issue removes the forward pass from
reconcileSilos, keeping only the reverse pass (ghost/orphan silo file cleanup). It also updates the maintenance.ts comment and the silo test suite.Step-by-step Implementation Plan
Step 1: Remove the forward pass from
reconcileSilos(src/lib/silo.ts)Function:
reconcileSilos(line 189)Replace the entire function body. Keep the signature and
ReconcileResultinterface unchanged.The current forward pass (lines 200–228) iterates
listWikiPages(), reads fromwikiRelPath(...)(flat), compares to silo, and syncs differences. After Part A, all writes go to silo, so flat content is stale/absent — this loop is dead code.New implementation:
Step 2: Remove unused
wikiRelPathimport fromsrc/lib/silo.tsWAIT — check first.
wikiRelPathis also used insyncSiloForPage(line ~91, 107, 111). That function still exists and is used by other code paths (manual migration, admin rebuild). So:wikiRelPathfrom silo.ts. It is still used bysyncSiloForPage.Step 3: Update maintenance.ts comment (line 274–276)
File:
src/lib/maintenance.ts, lines 274–276Replace the comment:
Step 4: Remove forward-pass tests from
src/lib/__tests__/silo.test.tsRemove the following 6 tests from the
describe("reconcileSilos", ...)block:Keep the "removes ghost silo files that have no index entry" test (~line 218). But update it: the test currently calls
writeWikiPage(which after Part A writes to silo), thensyncSiloForPage. Since the forward pass is gone, plant the real page's silo file directly:Step 5: Remove unused imports from silo.test.ts
After removing the forward-pass tests, check whether
syncSiloForPageis still used in the remainingreconcileSilostests. If NOT, and ifsyncSiloForPagetests in the same file still import it, leave it. But ifwriteWikiPageis no longer used anywhere in the file, remove it from the import.Check the
syncSiloForPagedescribe block — those tests still needwriteWikiPageto set up flat content for syncing. SowriteWikiPagelikely stays.Gotchas
wikiRelPathfrom silo.ts —syncSiloForPagestill uses it.syncSiloForPage— it is still called by admin/migration routes.ReconcileResultinterface shape must stay unchanged —synced,stale, andalreadyCurrentwill always be 0, but the shape is part of the API contract.listWikiPagesdynamic import (await import("./wiki")) stays — the reverse pass needs the page slug set.SKIPconstant stays — the reverse pass uses it to skip infrastructure slugs.Acceptance Criteria
reconcileSilos()no longer reads fromwikiRelPath(...)(flat path) — grep confirms noflatPathorflatContentvariable in the functionreconcileSilos()still removes ghost silo files (reverse pass works)ReconcileResultinterface is unchangedreconcileSilostests are removedpnpm buildpassespnpm testpassesSize Estimate
Medium — 3 files (silo.ts, silo.test.ts, maintenance.ts), ~50 lines deleted, ~10 lines added
Dependencies