Context
Part of the flat file retirement. The dependency (#869) is resolved. The write side (saveRevision) already accepts tenant?: string. The read functions still hardcode the flat path (wiki/.revisions/<slug>/). This issue makes them silo-aware.
Implementation Plan
Step 1: Update listRevisions in src/lib/revisions.ts
Add tenant?: string parameter after slug:
export async function listRevisions(slug: string, tenant?: string): Promise<Revision[]> {
Change the dirPath resolution:
const dirPath = tenant
? tenantRevisionsRelPath(tenant, slug)
: revisionsRelPath(slug);
Also update the inner path calls (for stat and readFile of .meta.json):
const filePath = tenant
? tenantRevisionsRelPath(tenant, slug, entry.name)
: revisionsRelPath(slug, entry.name);
const stat = await storage.stat(filePath);
And for the meta sidecar:
const metaPath = tenant
? tenantRevisionsRelPath(tenant, slug, `${stem}.meta.json`)
: revisionsRelPath(slug, `${stem}.meta.json`);
const metaRaw = await storage.readFile(metaPath);
Step 2: Update listRevisionAuthors in src/lib/revisions.ts
Add tenant?: string parameter:
export async function listRevisionAuthors(slug: string, max: number, tenant?: string): Promise<RevisionAuthor[]> {
Update dirPath:
const dirPath = tenant
? tenantRevisionsRelPath(tenant, slug)
: revisionsRelPath(slug);
Update the meta read inside the Promise.all:
const raw = await storage.readFile(
tenant
? tenantRevisionsRelPath(tenant, slug, `${timestamp}.meta.json`)
: revisionsRelPath(slug, `${timestamp}.meta.json`)
);
Step 3: Update readRevision in src/lib/revisions.ts
Add tenant?: string parameter:
export async function readRevision(slug: string, timestamp: number, tenant?: string): Promise<string | null> {
Update the path:
const relPath = tenant
? tenantRevisionsRelPath(tenant, slug, `${timestamp}.md`)
: revisionsRelPath(slug, `${timestamp}.md`);
return await getStorage().readFile(relPath);
Step 4: Update readRevisionMeta in src/lib/revisions.ts
Add tenant?: string parameter:
export async function readRevisionMeta(slug: string, timestamp: number, tenant?: string): Promise<RevisionMeta | null> {
Update the path:
const relPath = tenant
? tenantRevisionsRelPath(tenant, slug, `${timestamp}.meta.json`)
: revisionsRelPath(slug, `${timestamp}.meta.json`);
const raw = await getStorage().readFile(relPath);
Step 5: Update deleteRevisions in src/lib/revisions.ts
Add tenant?: string parameter:
export async function deleteRevisions(slug: string, tenant?: string): Promise<void> {
Delete from silo when tenant is provided, always attempt flat deletion:
if (tenant) {
try {
await getStorage().deleteDirectory(tenantRevisionsRelPath(tenant, slug));
} catch (err) {
if (!isEnoent(err)) {
logger.warn("revisions", `unexpected error deleting silo revisions for "${slug}":`, err);
}
}
}
// Always attempt flat deletion for backward compat cleanup
try {
await getStorage().deleteDirectory(revisionsRelPath(slug));
} catch (err) {
if (!isEnoent(err)) {
logger.warn("revisions", `unexpected error deleting revisions for "${slug}":`, err);
}
}
Step 6: Update callers
src/lib/lifecycle.ts line ~310 — the deleteRevisions(slug) call:
tenantForOwner is already imported
deleteTenant is already computed above as tenantForOwner(deletedOwner)
- Change to:
deleteRevisions(slug, deleteTenant)
(Note: deleteTenant might be undefined at that point if the pre-read failed; that is fine — deleteRevisions with undefined tenant just does flat-only delete)
Wait — actually deleteTenant is scoped differently. Look at line ~256: const deleteTenant = tenantForOwner(deletedOwner) but that's only computed when we enter the delete branch (the else at line ~240). The deleteRevisions(slug) call at line ~310 is inside that same else branch. So deleteTenant IS in scope. But it might have been computed with deletedOwner = undefined (if the pre-read catch fired). In that case tenantForOwner(undefined) returns the default tenant. This is correct — delete from default tenant silo + flat.
Actually re-reading: deleteTenant is declared with const at line ~256 which is inside a try/catch block. The deleteRevisions call at line ~310 is AFTER that. The variable IS accessible. Change to: deleteRevisions(slug, deleteTenant)
src/lib/trail.ts line ~236 — the listRevisionAuthors(page.slug, MAX_REVISIONS_PER_PAGE) call:
ownerToTenant is already imported (line 1)
page.owner is available in scope
- Change to:
listRevisionAuthors(page.slug, MAX_REVISIONS_PER_PAGE, ownerToTenant(page.owner))
src/lib/contributors.ts line ~220 — the listRevisions(p.slug) call:
- Add import of
ownerToTenant from "./wiki" (check existing imports first — listReadableWikiPages is already imported from "./wiki")
- The
pages array comes from listReadableWikiPages(principal) which returns WikiPage[] with .owner property
- Change to:
listRevisions(p.slug, ownerToTenant(p.owner))
src/app/api/wiki/[slug]/revisions/route.ts — 4 calls total:
- Add import:
import { tenantForSlug } from "@/lib/wiki"; (add to existing wiki import line)
- In GET handler, after the page existence check succeeds (~line 20), add:
const tenant = await tenantForSlug(slug);
- Pass
tenant to: readRevision(slug, timestamp, tenant), readRevisionMeta(slug, timestamp, tenant), listRevisions(slug, tenant)
- In POST handler, after
readWikiPageWithFrontmatter(slug) succeeds, add: const tenant = await tenantForSlug(slug);
- Pass
tenant to: readRevision(slug, timestamp, tenant)
src/mcp.ts — 4 calls across 3 handlers:
- Add
tenantForSlug to the import from "./lib/wiki" (line 64-74)
- In
handleListRevisions: add const tenant = await tenantForSlug(args.slug); then pass to listRevisions(args.slug, tenant)
- In
handleReadRevision: add const tenant = await tenantForSlug(args.slug); then pass to readRevision(args.slug, args.timestamp, tenant) and readRevisionMeta(args.slug, args.timestamp, tenant)
- In
handleRevertRevision: add const tenant = await tenantForSlug(args.slug); then pass to readRevision(args.slug, args.timestamp, tenant)
Step 7: Add tests in src/lib/__tests__/revisions.test.ts
Add a new describe block after the existing "saveRevision with tenant parameter" block (after line 528):
describe("revision reads with tenant parameter", () => {
it("listRevisions reads from tenant silo when tenant is provided", async () => {
await ensureDirectories();
await saveRevision("tenant-read", "# Rev1", "alice", "first", "alice");
const revs = await listRevisions("tenant-read", "alice");
expect(revs.length).toBe(1);
expect(revs[0].slug).toBe("tenant-read");
expect(revs[0].author).toBe("alice");
});
it("listRevisions returns empty for wrong tenant", async () => {
await ensureDirectories();
await saveRevision("tenant-read2", "# Rev1", "alice", "first", "alice");
const revs = await listRevisions("tenant-read2", "bob");
expect(revs.length).toBe(0);
});
it("readRevision reads from tenant silo", async () => {
await ensureDirectories();
await saveRevision("tenant-read3", "# Content", "alice", "r", "alice");
const revs = await listRevisions("tenant-read3", "alice");
const content = await readRevision("tenant-read3", revs[0].timestamp, "alice");
expect(content).toBe("# Content");
});
it("readRevisionMeta reads from tenant silo", async () => {
await ensureDirectories();
await saveRevision("tenant-read4", "# C", "alice", "reason1", "alice");
const revs = await listRevisions("tenant-read4", "alice");
const meta = await readRevisionMeta("tenant-read4", revs[0].timestamp, "alice");
expect(meta?.author).toBe("alice");
expect(meta?.reason).toBe("reason1");
});
it("listRevisionAuthors reads from tenant silo", async () => {
await ensureDirectories();
await saveRevision("tenant-read5", "# C", "bob", "edit", "bob");
const authors = await listRevisionAuthors("tenant-read5", 10, "bob");
expect(authors.length).toBe(1);
expect(authors[0].author).toBe("bob");
});
it("deleteRevisions removes from both silo and flat", async () => {
await ensureDirectories();
// Write to silo
await saveRevision("tenant-del", "# C", "alice", "r", "alice");
// Write to flat
await saveRevision("tenant-del", "# C2", "alice", "r2");
// Verify both exist
expect((await listRevisions("tenant-del", "alice")).length).toBe(1);
expect((await listRevisions("tenant-del")).length).toBe(1);
// Delete with tenant
await deleteRevisions("tenant-del", "alice");
// Both should be gone
expect((await listRevisions("tenant-del", "alice")).length).toBe(0);
expect((await listRevisions("tenant-del")).length).toBe(0);
});
});
Files Involved
src/lib/revisions.ts — add tenant?: string to all 5 functions, update storage paths
src/lib/lifecycle.ts — pass deleteTenant to deleteRevisions call
src/lib/trail.ts — pass ownerToTenant(page.owner) to listRevisionAuthors
src/lib/contributors.ts — add ownerToTenant import, pass to listRevisions
src/app/api/wiki/[slug]/revisions/route.ts — resolve tenant via tenantForSlug, pass to all revision reads
src/mcp.ts — resolve tenant via tenantForSlug, pass to all revision reads
src/lib/__tests__/revisions.test.ts — new test block for tenant-aware reads
Gotchas for Build Agent
- Parameter ordering:
tenant goes LAST on every function to preserve backward compat. All existing callers that omit it still work.
ownerToTenant vs tenantForSlug: In callers that already have the page/owner (lifecycle, trail, contributors), use synchronous ownerToTenant(page.owner). In callers that only have a slug (API routes, MCP), use async await tenantForSlug(slug).
- No silo-to-flat fallback on reads: Unlike
readWikiPage, revision reads do NOT need fallback. The caller provides the correct tenant. Old flat-only revisions are either migrated by syncSiloForPage or still accessible via omitting the tenant param.
deleteRevisions must delete from BOTH locations because old revisions may still live in flat from before silo writes.
- contributors.ts import:
ownerToTenant is re-exported from "./wiki" (check the existing import line from "./wiki" and add it there).
- lifecycle.ts scope:
deleteTenant is computed as const deleteTenant = tenantForOwner(deletedOwner) inside the delete branch. It is in scope where deleteRevisions is called. Just pass it.
- DO NOT add fallback reads (try silo, fall back to flat) — that would complicate the logic. Simple conditional: if tenant provided, read silo; if not, read flat.
Acceptance Criteria
Size Estimate
Small-medium — 7 files but all changes are mechanical. The pattern is identical across all 5 functions.
Context
Part of the flat file retirement. The dependency (#869) is resolved. The write side (
saveRevision) already acceptstenant?: string. The read functions still hardcode the flat path (wiki/.revisions/<slug>/). This issue makes them silo-aware.Implementation Plan
Step 1: Update
listRevisionsinsrc/lib/revisions.tsAdd
tenant?: stringparameter afterslug:Change the
dirPathresolution:Also update the inner path calls (for
statandreadFileof.meta.json):And for the meta sidecar:
Step 2: Update
listRevisionAuthorsinsrc/lib/revisions.tsAdd
tenant?: stringparameter:Update
dirPath:Update the meta read inside the
Promise.all:Step 3: Update
readRevisioninsrc/lib/revisions.tsAdd
tenant?: stringparameter:Update the path:
Step 4: Update
readRevisionMetainsrc/lib/revisions.tsAdd
tenant?: stringparameter:Update the path:
Step 5: Update
deleteRevisionsinsrc/lib/revisions.tsAdd
tenant?: stringparameter:Delete from silo when tenant is provided, always attempt flat deletion:
Step 6: Update callers
src/lib/lifecycle.tsline ~310 — thedeleteRevisions(slug)call:tenantForOwneris already importeddeleteTenantis already computed above astenantForOwner(deletedOwner)deleteRevisions(slug, deleteTenant)(Note:
deleteTenantmight be undefined at that point if the pre-read failed; that is fine —deleteRevisionswith undefined tenant just does flat-only delete)Wait — actually
deleteTenantis scoped differently. Look at line ~256:const deleteTenant = tenantForOwner(deletedOwner)but that's only computed when we enter the delete branch (theelseat line ~240). ThedeleteRevisions(slug)call at line ~310 is inside that sameelsebranch. SodeleteTenantIS in scope. But it might have been computed withdeletedOwner = undefined(if the pre-read catch fired). In that casetenantForOwner(undefined)returns the default tenant. This is correct — delete from default tenant silo + flat.Actually re-reading:
deleteTenantis declared withconstat line ~256 which is inside a try/catch block. ThedeleteRevisionscall at line ~310 is AFTER that. The variable IS accessible. Change to:deleteRevisions(slug, deleteTenant)src/lib/trail.tsline ~236 — thelistRevisionAuthors(page.slug, MAX_REVISIONS_PER_PAGE)call:ownerToTenantis already imported (line 1)page.owneris available in scopelistRevisionAuthors(page.slug, MAX_REVISIONS_PER_PAGE, ownerToTenant(page.owner))src/lib/contributors.tsline ~220 — thelistRevisions(p.slug)call:ownerToTenantfrom"./wiki"(check existing imports first —listReadableWikiPagesis already imported from"./wiki")pagesarray comes fromlistReadableWikiPages(principal)which returnsWikiPage[]with.ownerpropertylistRevisions(p.slug, ownerToTenant(p.owner))src/app/api/wiki/[slug]/revisions/route.ts— 4 calls total:import { tenantForSlug } from "@/lib/wiki";(add to existing wiki import line)const tenant = await tenantForSlug(slug);tenantto:readRevision(slug, timestamp, tenant),readRevisionMeta(slug, timestamp, tenant),listRevisions(slug, tenant)readWikiPageWithFrontmatter(slug)succeeds, add:const tenant = await tenantForSlug(slug);tenantto:readRevision(slug, timestamp, tenant)src/mcp.ts— 4 calls across 3 handlers:tenantForSlugto the import from"./lib/wiki"(line 64-74)handleListRevisions: addconst tenant = await tenantForSlug(args.slug);then pass tolistRevisions(args.slug, tenant)handleReadRevision: addconst tenant = await tenantForSlug(args.slug);then pass toreadRevision(args.slug, args.timestamp, tenant)andreadRevisionMeta(args.slug, args.timestamp, tenant)handleRevertRevision: addconst tenant = await tenantForSlug(args.slug);then pass toreadRevision(args.slug, args.timestamp, tenant)Step 7: Add tests in
src/lib/__tests__/revisions.test.tsAdd a new
describeblock after the existing "saveRevision with tenant parameter" block (after line 528):Files Involved
src/lib/revisions.ts— addtenant?: stringto all 5 functions, update storage pathssrc/lib/lifecycle.ts— passdeleteTenanttodeleteRevisionscallsrc/lib/trail.ts— passownerToTenant(page.owner)tolistRevisionAuthorssrc/lib/contributors.ts— addownerToTenantimport, pass tolistRevisionssrc/app/api/wiki/[slug]/revisions/route.ts— resolve tenant viatenantForSlug, pass to all revision readssrc/mcp.ts— resolve tenant viatenantForSlug, pass to all revision readssrc/lib/__tests__/revisions.test.ts— new test block for tenant-aware readsGotchas for Build Agent
tenantgoes LAST on every function to preserve backward compat. All existing callers that omit it still work.ownerToTenantvstenantForSlug: In callers that already have the page/owner (lifecycle, trail, contributors), use synchronousownerToTenant(page.owner). In callers that only have a slug (API routes, MCP), use asyncawait tenantForSlug(slug).readWikiPage, revision reads do NOT need fallback. The caller provides the correct tenant. Old flat-only revisions are either migrated bysyncSiloForPageor still accessible via omitting the tenant param.deleteRevisionsmust delete from BOTH locations because old revisions may still live in flat from before silo writes.ownerToTenantis re-exported from"./wiki"(check the existing import line from"./wiki"and add it there).deleteTenantis computed asconst deleteTenant = tenantForOwner(deletedOwner)inside the delete branch. It is in scope wheredeleteRevisionsis called. Just pass it.Acceptance Criteria
pnpm build && pnpm lint && pnpm testpassessrc/lib/revisions.tsaccepttenant?: stringas their last parametertenantis provided, reads targettenants/<tenant>/wiki/.revisions/<slug>/tenantis omitted, reads targetwiki/.revisions/<slug>/(backward compat)deleteRevisionswith tenant deletes from BOTH silo and flatSize Estimate
Small-medium — 7 files but all changes are mechanical. The pattern is identical across all 5 functions.