Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,19 @@ exports.up = async function up(knex) {
table.primary(['site_ref', 'section_ref']);
table.index(['entity_owner_ref'], 'section_ownership_owner_idx');
});
// The `sections` table is dense (one row per section) and carries both structure
// (parent_section_ref) and the effective-ownership rollup (entity_ref, entity_owner_ref, and an
// owner-relative section_path). The owner index is pre-positioned for the inbox's
// "sections owned by X" query (the consumer lands in a separate change).
await knex.schema.createTable('sections', table => {
table.text('site_ref').notNullable();
table.text('section_ref').notNullable();
table.text('section_path').notNullable();
table.text('parent_section_ref').nullable();
table.text('entity_ref').notNullable();
table.text('entity_owner_ref').nullable();
table.primary(['site_ref', 'section_ref']);
table.index(['entity_owner_ref'], 'sections_owner_idx');
});
await knex.schema.createTable('pages', table => {
table.text('site_ref').notNullable();
Expand Down
3 changes: 2 additions & 1 deletion plugins/rw-backend/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,8 @@ export const rwPlugin = createBackendPlugin({
id: "rw-site-index-worker",
scope: "local",
...workerSchedule,
fn: async () => runWorker({ logger, siteRefreshStore, registryStore, makeSite }),
fn: async () =>
runWorker({ logger, siteRefreshStore, registryStore, sectionOwnershipStore, makeSite }),
});
logger.info(
`Scheduled site index rebuild: scan ${JSON.stringify(scanSchedule.frequency)} (global), ` +
Expand Down
6 changes: 6 additions & 0 deletions plugins/rw-backend/src/siteIndex/RegistryStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ describe("RegistryStore", () => {
section_ref: "s1",
section_path: "",
parent_section_ref: null,
entity_ref: "component:default/a",
entity_owner_ref: null,
},
],
[{ site_ref: "component:default/a", section_ref: "s1", subpath: "", title: "Home" }],
Expand All @@ -30,6 +32,8 @@ describe("RegistryStore", () => {
section_ref: "sb1",
section_path: "b",
parent_section_ref: null,
entity_ref: "component:default/b",
entity_owner_ref: null,
},
],
[{ site_ref: "component:default/b", section_ref: "sb1", subpath: "bp", title: "B Home" }],
Expand All @@ -42,6 +46,8 @@ describe("RegistryStore", () => {
section_ref: "s2",
section_path: "x",
parent_section_ref: "s1",
entity_ref: "component:default/a",
entity_owner_ref: null,
},
],
[{ site_ref: "component:default/a", section_ref: "s2", subpath: "p", title: "P" }],
Expand Down
28 changes: 28 additions & 0 deletions plugins/rw-backend/src/siteIndex/SectionOwnershipStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,32 @@ describe("SectionOwnershipStore", () => {
expect(rowA?.entity_ref).toBe("e3");
expect(rowA?.entity_owner_ref).toBe("g3");
});

it("listForSite returns only that site's claims", async () => {
const store = new SectionOwnershipStore(knex);
await store.swapSite("component:default/a", [
{
site_ref: "component:default/a",
section_ref: "section:default/x",
entity_ref: "e1",
entity_owner_ref: "o1",
},
]);
await store.swapSite("component:default/b", [
{
site_ref: "component:default/b",
section_ref: "section:default/y",
entity_ref: "e2",
entity_owner_ref: null,
},
]);
const rows = await store.listForSite("component:default/a");
expect(rows).toHaveLength(1);
expect(rows[0]).toEqual({
site_ref: "component:default/a",
section_ref: "section:default/x",
entity_ref: "e1",
entity_owner_ref: "o1",
});
});
});
5 changes: 5 additions & 0 deletions plugins/rw-backend/src/siteIndex/SectionOwnershipStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,9 @@ export class SectionOwnershipStore {
await exec(TABLE).where({ site_ref: siteRef }).del();
if (links.length) await exec.batchInsert(TABLE, links, 500);
}

/** Return all ownership links for `siteRef`. */
async listForSite(siteRef: string): Promise<SectionOwnershipRow[]> {
return this.knex(TABLE).where({ site_ref: siteRef }).select("*");
}
}
162 changes: 162 additions & 0 deletions plugins/rw-backend/src/siteIndex/effectiveOwnership.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { computeSectionRows } from "./effectiveOwnership";
import type { SectionOwnershipRow } from "./types";

const SITE = "component:default/docs";
const sections = [
{ sectionRef: "section:default/root", path: "", ancestors: [] },
{
sectionRef: "section:default/billing",
path: "systems/billing",
ancestors: ["section:default/root"],
},
{
sectionRef: "section:default/billing-api",
path: "systems/billing/api",
ancestors: ["section:default/billing", "section:default/root"],
},
];

it("attributes a section to its nearest claiming ancestor and relativizes the path", () => {
const claims: SectionOwnershipRow[] = [
{
site_ref: SITE,
section_ref: "section:default/billing",
entity_ref: "domain:default/billing",
entity_owner_ref: "group:default/billing-team",
},
];
const rows = computeSectionRows(SITE, sections, claims);
const api = rows.find((r) => r.section_ref === "section:default/billing-api")!;
expect(api.entity_ref).toBe("domain:default/billing");
expect(api.entity_owner_ref).toBe("group:default/billing-team");
expect(api.section_path).toBe("api"); // "systems/billing/api" relative to claimer "systems/billing"
// parent_section_ref is the immediate parent (ancestors[0], nearest-first)
expect(api.parent_section_ref).toBe("section:default/billing");
expect(api.site_ref).toBe(SITE);

// The direct claimer's own path is empty: stripPrefix(full, full) = ""
const billing = rows.find((r) => r.section_ref === "section:default/billing")!;
expect(billing.section_path).toBe("");
expect(billing.parent_section_ref).toBe("section:default/root");

// A root section has no parent
const root = rows.find((r) => r.section_ref === "section:default/root")!;
expect(root.parent_section_ref).toBeNull();
});

it("attributes to the nearest ancestor when both a near and a far ancestor are claimed", () => {
const claims: SectionOwnershipRow[] = [
// far ancestor: root sentinel
{
site_ref: SITE,
section_ref: SITE,
entity_ref: SITE,
entity_owner_ref: "group:default/site-owner",
},
// near ancestor: billing (more specific than root)
{
site_ref: SITE,
section_ref: "section:default/billing",
entity_ref: "domain:default/billing",
entity_owner_ref: "group:default/billing-team",
},
];
const rows = computeSectionRows(SITE, sections, claims);
const api = rows.find((r) => r.section_ref === "section:default/billing-api")!;
// Must resolve to the NEAR claim (billing), not the far root sentinel
expect(api.entity_ref).toBe("domain:default/billing");
expect(api.entity_owner_ref).toBe("group:default/billing-team");
expect(api.section_path).toBe("api"); // relative to billing's path "systems/billing"
});

it("falls back to the site-root sentinel for unclaimed sections", () => {
const claims: SectionOwnershipRow[] = [
{
site_ref: SITE,
section_ref: SITE,
entity_ref: SITE,
entity_owner_ref: "group:default/site-owner",
},
];
const rows = computeSectionRows(SITE, sections, claims);
const billing = rows.find((r) => r.section_ref === "section:default/billing")!;
expect(billing.entity_ref).toBe(SITE);
expect(billing.entity_owner_ref).toBe("group:default/site-owner");
expect(billing.section_path).toBe("systems/billing"); // unchanged (site-root scope, empty prefix)
});

it("uses null owner when there is no sentinel and no claim", () => {
const rows = computeSectionRows(SITE, sections, []);
const root = rows.find((r) => r.section_ref === "section:default/root")!;
expect(root.entity_ref).toBe(SITE);
expect(root.entity_owner_ref).toBeNull();
const billing = rows.find((r) => r.section_ref === "section:default/billing")!;
expect(billing.entity_ref).toBe(SITE);
expect(billing.entity_owner_ref).toBeNull();
const billingApi = rows.find((r) => r.section_ref === "section:default/billing-api")!;
expect(billingApi.entity_ref).toBe(SITE);
expect(billingApi.entity_owner_ref).toBeNull();
});

it("keeps a direct claim's null owner; does not inherit the site-root sentinel owner", () => {
const claims: SectionOwnershipRow[] = [
// site-root sentinel with a real owner
{
site_ref: SITE,
section_ref: SITE,
entity_ref: SITE,
entity_owner_ref: "group:default/site-owner",
},
// direct claim on billing whose entity has no owner relation (null owner)
{
site_ref: SITE,
section_ref: "section:default/billing",
entity_ref: "domain:default/billing",
entity_owner_ref: null,
},
];
const rows = computeSectionRows(SITE, sections, claims);
// The direct claim wins: billing is attributed to its entity with a null owner,
// NOT the sentinel's owner — a claimed section is never re-owned by the site fallback.
const billing = rows.find((r) => r.section_ref === "section:default/billing")!;
expect(billing.entity_ref).toBe("domain:default/billing");
expect(billing.entity_owner_ref).toBeNull();
// Its descendant inherits the same direct claim, also with a null owner.
const billingApi = rows.find((r) => r.section_ref === "section:default/billing-api")!;
expect(billingApi.entity_ref).toBe("domain:default/billing");
expect(billingApi.entity_owner_ref).toBeNull();
// A sibling with no claim still falls back to the sentinel owner.
const root = rows.find((r) => r.section_ref === "section:default/root")!;
expect(root.entity_owner_ref).toBe("group:default/site-owner");
});

it("strips the claimer prefix only on a path-segment boundary, not a shared string prefix", () => {
// billing-x shares the string prefix "systems/billing" with its claiming ancestor billing,
// but "systems/billingX" is not under "systems/billing/" — the prefix must NOT be stripped.
const boundarySections = [
{ sectionRef: "section:default/root", path: "", ancestors: [] },
{
sectionRef: "section:default/billing",
path: "systems/billing",
ancestors: ["section:default/root"],
},
{
sectionRef: "section:default/billing-x",
path: "systems/billingX",
ancestors: ["section:default/billing", "section:default/root"],
},
];
const claims: SectionOwnershipRow[] = [
{
site_ref: SITE,
section_ref: "section:default/billing",
entity_ref: "domain:default/billing",
entity_owner_ref: "group:default/billing-team",
},
];
const rows = computeSectionRows(SITE, boundarySections, claims);
const billingX = rows.find((r) => r.section_ref === "section:default/billing-x")!;
// Attributed to the claiming ancestor, but the path is left intact (no spurious strip to "X").
expect(billingX.entity_ref).toBe("domain:default/billing");
expect(billingX.section_path).toBe("systems/billingX");
});
49 changes: 49 additions & 0 deletions plugins/rw-backend/src/siteIndex/effectiveOwnership.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { SectionOwnershipRow, SectionRow } from "./types";

export interface RawSection {
sectionRef: string;
path: string;
ancestors: string[]; // nearest-first
}

/** Build the dense `sections` registry: one row per section carrying both structure
* (parent_section_ref) and effective ownership. A section is attributed to its nearest claiming
* ancestor (incl. itself), else the site-root sentinel (section_ref === siteRef), else a
* null-owner site fallback. The claimer's path is stripped so descendant paths become relative
* to the owning entity's docs root. */
export function computeSectionRows(
siteRef: string,
sections: RawSection[],
claims: SectionOwnershipRow[],
): SectionRow[] {
const sentinel = claims.find((c) => c.section_ref === siteRef);
// Sentinel excluded from realClaims so it doesn't shadow per-section claims;
// its owner is only the fallback for sections with no more-specific claim.
const realClaims = new Map(
claims.filter((c) => c.section_ref !== siteRef).map((c) => [c.section_ref, c]),
);
const pathByRef = new Map(sections.map((s) => [s.sectionRef, s.path]));
const siteOwnerRef = sentinel?.entity_owner_ref ?? null;

return sections.map((s) => {
const claimerRef = [s.sectionRef, ...s.ancestors].find((ref) => realClaims.has(ref));
const claim = claimerRef ? realClaims.get(claimerRef)! : null;
const entity_ref = claim?.entity_ref ?? siteRef;
const entity_owner_ref = claim ? claim.entity_owner_ref : siteOwnerRef;
const claimerPath = claimerRef ? (pathByRef.get(claimerRef) ?? "") : "";
return {
site_ref: siteRef,
section_ref: s.sectionRef,
section_path: stripPrefix(s.path, claimerPath),
parent_section_ref: s.ancestors[0] ?? null, // ancestors is nearest-first; [0] is immediate parent
entity_ref,
entity_owner_ref,
};
});
}

function stripPrefix(full: string, prefix: string): string {
if (!prefix) return full;
if (full === prefix) return "";
return full.startsWith(`${prefix}/`) ? full.slice(prefix.length + 1) : full;
}
23 changes: 23 additions & 0 deletions plugins/rw-backend/src/siteIndex/migrations.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { createTestDb } from "./__testUtils__/testDb";

describe("siteIndex migrations", () => {
it("sections table carries the effective-ownership columns + owner index", async () => {
const knex = await createTestDb();
try {
const cols = await knex("sections").columnInfo();
expect(cols.entity_ref).toBeDefined();
expect(cols.entity_owner_ref).toBeDefined();
// entity_ref is NOT NULL, entity_owner_ref is nullable
expect(cols.entity_ref.nullable).toBe(false);
expect(cols.entity_owner_ref.nullable).toBe(true);

// knex.raw returns the row array directly on the better-sqlite3 backend; one row = index exists.
const idx = await knex.raw(
"SELECT name FROM sqlite_master WHERE type='index' AND name='sections_owner_idx'",
);
expect(idx).toHaveLength(1);
} finally {
await knex.destroy();
}
});
});
9 changes: 8 additions & 1 deletion plugins/rw-backend/src/siteIndex/registryHash.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,14 @@ import { registryHash } from "./registryHash";
describe("registryHash", () => {
it("is stable for identical input and changes when content changes", () => {
const sections = [
{ site_ref: "a", section_ref: "s1", section_path: "", parent_section_ref: null },
{
site_ref: "a",
section_ref: "s1",
section_path: "",
parent_section_ref: null,
entity_ref: "a",
entity_owner_ref: null,
},
];
const pages = [{ site_ref: "a", section_ref: "s1", subpath: "", title: "Home" }];
const h1 = registryHash(sections, pages);
Expand Down
Loading