From 4fe6bce84f851759a933cf0f630a7badc1459db6 Mon Sep 17 00:00:00 2001 From: Mike Yumatov Date: Thu, 25 Jun 2026 06:55:19 +0300 Subject: [PATCH] feat(rw-backend): materialize effective section ownership in siteIndex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The siteIndex worker already materializes the dense `sections` registry from RwSite.listSections(). This adds an effective-ownership rollup: the worker now loads per-site ownership claims (written to section_ownership by the scan) and folds an effective owner into every section row. - migration: add entity_ref / entity_owner_ref + sections_owner_idx to `sections` (edited in place — siteIndex is unreleased) - effectiveOwnership.ts: pure computeSectionRows — attributes each section to its nearest claiming ancestor, falls back to the site-root sentinel then a null owner, and strips the claimer's path prefix so section_path is owner-relative - SectionOwnershipStore.listForSite: read side for the worker - runWorker: fetch claims alongside listSections/listPages and roll them up - plugin.ts: pass the existing sectionOwnershipStore into the worker Infra-only: no consumer reads the new columns yet (the comment inbox, a separate change, is the first reader). Known deferred debt: the scan (writes claims) and worker (reads claims) couple through section_ownership with no read-time consistency guard; bounded and self-healing within ~one scan cycle since ownership is part of the hashed SectionRow. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...20260624000000_create_site_index_tables.js | 7 + plugins/rw-backend/src/plugin.ts | 3 +- .../src/siteIndex/RegistryStore.test.ts | 6 + .../siteIndex/SectionOwnershipStore.test.ts | 28 +++ .../src/siteIndex/SectionOwnershipStore.ts | 5 + .../src/siteIndex/effectiveOwnership.test.ts | 162 ++++++++++++++++++ .../src/siteIndex/effectiveOwnership.ts | 49 ++++++ .../src/siteIndex/migrations.test.ts | 23 +++ .../src/siteIndex/registryHash.test.ts | 9 +- .../src/siteIndex/runWorker.test.ts | 107 ++++++++++++ plugins/rw-backend/src/siteIndex/runWorker.ts | 21 +-- plugins/rw-backend/src/siteIndex/types.ts | 7 +- 12 files changed, 414 insertions(+), 13 deletions(-) create mode 100644 plugins/rw-backend/src/siteIndex/effectiveOwnership.test.ts create mode 100644 plugins/rw-backend/src/siteIndex/effectiveOwnership.ts create mode 100644 plugins/rw-backend/src/siteIndex/migrations.test.ts diff --git a/plugins/rw-backend/migrations/20260624000000_create_site_index_tables.js b/plugins/rw-backend/migrations/20260624000000_create_site_index_tables.js index 2e66c49..3e86473 100644 --- a/plugins/rw-backend/migrations/20260624000000_create_site_index_tables.js +++ b/plugins/rw-backend/migrations/20260624000000_create_site_index_tables.js @@ -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(); diff --git a/plugins/rw-backend/src/plugin.ts b/plugins/rw-backend/src/plugin.ts index 0d56db5..9007ca5 100644 --- a/plugins/rw-backend/src/plugin.ts +++ b/plugins/rw-backend/src/plugin.ts @@ -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), ` + diff --git a/plugins/rw-backend/src/siteIndex/RegistryStore.test.ts b/plugins/rw-backend/src/siteIndex/RegistryStore.test.ts index 5dd097c..beaccd9 100644 --- a/plugins/rw-backend/src/siteIndex/RegistryStore.test.ts +++ b/plugins/rw-backend/src/siteIndex/RegistryStore.test.ts @@ -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" }], @@ -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" }], @@ -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" }], diff --git a/plugins/rw-backend/src/siteIndex/SectionOwnershipStore.test.ts b/plugins/rw-backend/src/siteIndex/SectionOwnershipStore.test.ts index 258efc9..b7a4460 100644 --- a/plugins/rw-backend/src/siteIndex/SectionOwnershipStore.test.ts +++ b/plugins/rw-backend/src/siteIndex/SectionOwnershipStore.test.ts @@ -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", + }); + }); }); diff --git a/plugins/rw-backend/src/siteIndex/SectionOwnershipStore.ts b/plugins/rw-backend/src/siteIndex/SectionOwnershipStore.ts index bd845cd..b3f42d1 100644 --- a/plugins/rw-backend/src/siteIndex/SectionOwnershipStore.ts +++ b/plugins/rw-backend/src/siteIndex/SectionOwnershipStore.ts @@ -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 { + return this.knex(TABLE).where({ site_ref: siteRef }).select("*"); + } } diff --git a/plugins/rw-backend/src/siteIndex/effectiveOwnership.test.ts b/plugins/rw-backend/src/siteIndex/effectiveOwnership.test.ts new file mode 100644 index 0000000..a8c2f37 --- /dev/null +++ b/plugins/rw-backend/src/siteIndex/effectiveOwnership.test.ts @@ -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"); +}); diff --git a/plugins/rw-backend/src/siteIndex/effectiveOwnership.ts b/plugins/rw-backend/src/siteIndex/effectiveOwnership.ts new file mode 100644 index 0000000..83825e9 --- /dev/null +++ b/plugins/rw-backend/src/siteIndex/effectiveOwnership.ts @@ -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; +} diff --git a/plugins/rw-backend/src/siteIndex/migrations.test.ts b/plugins/rw-backend/src/siteIndex/migrations.test.ts new file mode 100644 index 0000000..d963e2f --- /dev/null +++ b/plugins/rw-backend/src/siteIndex/migrations.test.ts @@ -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(); + } + }); +}); diff --git a/plugins/rw-backend/src/siteIndex/registryHash.test.ts b/plugins/rw-backend/src/siteIndex/registryHash.test.ts index dcef7d0..e82b854 100644 --- a/plugins/rw-backend/src/siteIndex/registryHash.test.ts +++ b/plugins/rw-backend/src/siteIndex/registryHash.test.ts @@ -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); diff --git a/plugins/rw-backend/src/siteIndex/runWorker.test.ts b/plugins/rw-backend/src/siteIndex/runWorker.test.ts index fc12231..573849a 100644 --- a/plugins/rw-backend/src/siteIndex/runWorker.test.ts +++ b/plugins/rw-backend/src/siteIndex/runWorker.test.ts @@ -2,6 +2,7 @@ import type { Knex } from "knex"; import { createTestDb } from "./__testUtils__/testDb"; import { SiteRefreshStore } from "./SiteRefreshStore"; import { RegistryStore } from "./RegistryStore"; +import { SectionOwnershipStore } from "./SectionOwnershipStore"; import { runWorker } from "./runWorker"; function fakeSite() { @@ -11,10 +12,17 @@ function fakeSite() { }; } +const fakeSectionOwnershipStore = ( + claims: Awaited> = [], +) => ({ + listForSite: async (_siteRef: string) => claims, +}); + const deps = (knex: Knex, makeSite: any, now?: () => Date) => ({ logger: { warn() {}, info() {}, debug() {} } as any, siteRefreshStore: new SiteRefreshStore(knex), registryStore: new RegistryStore(knex), + sectionOwnershipStore: fakeSectionOwnershipStore(), makeSite, now, rng: () => 0.5, @@ -88,4 +96,103 @@ describe("runWorker", () => { }); expect(swaps).toBe(0); // unchanged content → no swap }); + + it("produces the same result_hash regardless of the order listSections returns sections (effective ownership sort)", async () => { + // Two separate DBs: one where listSections returns sections in forward order, + // another where they come back reversed. The effective ownership rows differ + // in insertion order but the resulting hash stored in site_refresh must be + // identical — proving that runWorker sorts effective before hashing. + const siteRef = "component:default/docs"; + + const sectionsForward = [ + { sectionRef: "component:default/a", path: "a", ancestors: [] }, + { sectionRef: "component:default/b", path: "b", ancestors: [] }, + ]; + const sectionsReversed = [...sectionsForward].reverse(); + const pages = [{ sectionRef: "component:default/a", subpath: "", title: "Home" }]; + + const claims = [ + { + site_ref: siteRef, + section_ref: "component:default/a", + entity_ref: siteRef, + entity_owner_ref: "group:default/owners", + }, + { + site_ref: siteRef, + section_ref: "component:default/b", + entity_ref: siteRef, + entity_owner_ref: "group:default/owners", + }, + ]; + const sectionOwnershipStore = fakeSectionOwnershipStore(claims); + + async function buildAndGetHash(listSectionsResult: typeof sectionsForward) { + const k = await createTestDb(); + try { + const store = new SiteRefreshStore(k); + await store.upsertSite(siteRef, new Date("2026-06-24T00:00:00Z")); + await runWorker({ + ...deps( + k, + () => ({ + listSections: async () => listSectionsResult, + listPages: async () => pages, + }), + () => new Date("2026-06-24T00:01:00Z"), + ), + sectionOwnershipStore, + rng: () => 0.5, + }); + const row = await k("site_refresh").where({ site_ref: siteRef }).first(); + return row.result_hash as string; + } finally { + await k.destroy(); + } + } + + const hashForward = await buildAndGetHash(sectionsForward); + const hashReversed = await buildAndGetHash(sectionsReversed); + expect(hashForward).toBe(hashReversed); + }); + + it("passes section rows carrying effective ownership to swapSite", async () => { + const siteRef = "component:default/docs"; + const store = new SiteRefreshStore(knex); + await store.upsertSite(siteRef, new Date("2026-06-24T00:00:00Z")); + + const claim = { + site_ref: siteRef, + section_ref: siteRef, + entity_ref: siteRef, + entity_owner_ref: "group:default/owners", + }; + const sectionOwnershipStore = fakeSectionOwnershipStore([claim]); + + let capturedSections: any[] | undefined; + const registryStore = new RegistryStore(knex); + const orig = registryStore.swapSite.bind(registryStore); + registryStore.swapSite = async (sr, sections, pages) => { + capturedSections = sections; + return orig(sr, sections, pages); + }; + + await runWorker({ + ...deps( + knex, + () => fakeSite(), + () => new Date("2026-06-24T00:01:00Z"), + ), + registryStore, + sectionOwnershipStore, + }); + + expect(capturedSections).toBeDefined(); + expect(capturedSections!.length).toBe(1); + expect(capturedSections![0].section_ref).toBe(siteRef); + // sentinel claim (section_ref === siteRef): section_path relative to root → "" + expect(capturedSections![0].section_path).toBe(""); + expect(capturedSections![0].entity_ref).toBe(siteRef); + expect(capturedSections![0].entity_owner_ref).toBe("group:default/owners"); + }); }); diff --git a/plugins/rw-backend/src/siteIndex/runWorker.ts b/plugins/rw-backend/src/siteIndex/runWorker.ts index e46ff1e..1994d70 100644 --- a/plugins/rw-backend/src/siteIndex/runWorker.ts +++ b/plugins/rw-backend/src/siteIndex/runWorker.ts @@ -4,8 +4,10 @@ import { toEntityPath } from "@rwdocs/backstage-plugin-rw-common"; import type { RwSite } from "@rwdocs/core"; import type { SiteRefreshStore } from "./SiteRefreshStore"; import type { RegistryStore } from "./RegistryStore"; +import type { SectionOwnershipStore } from "./SectionOwnershipStore"; import type { SectionRow, PageRow } from "./types"; import { registryHash } from "./registryHash"; +import { computeSectionRows } from "./effectiveOwnership"; import { jitteredNextUpdate, BATCH_SIZE, CONCURRENCY, LEASE_MS, INTERVAL_MS } from "./schedule"; function sortSections(rows: SectionRow[]): SectionRow[] { @@ -21,11 +23,12 @@ export async function runWorker(deps: { logger: LoggerService; siteRefreshStore: SiteRefreshStore; registryStore: RegistryStore; + sectionOwnershipStore: Pick; makeSite: (entityPath: string) => Pick; now?: () => Date; rng?: () => number; }): Promise { - const { logger, siteRefreshStore, registryStore, makeSite } = deps; + const { logger, siteRefreshStore, registryStore, sectionOwnershipStore, makeSite } = deps; const now = deps.now ?? (() => new Date()); const claimNow = now(); @@ -43,18 +46,16 @@ export async function runWorker(deps: { limit(async () => { try { const site = makeSite(toEntityPath(siteRef)); - const [rawSections, rawPages] = await Promise.all([ + // listForSite is independent of the doc-structure reads, so fetch all three together. + const [rawSections, rawPages, claims] = await Promise.all([ site.listSections(), site.listPages(), + sectionOwnershipStore.listForSite(siteRef), ]); - const sections = sortSections( - rawSections.map((s) => ({ - site_ref: siteRef, - section_ref: s.sectionRef, - section_path: s.path, - parent_section_ref: s.ancestors[0] ?? null, // ancestors is nearest-first; [0] is immediate parent - })), - ); + // computeSectionRows folds the effective-ownership rollup into each dense section row. + // registryHash is order-sensitive (JSON.stringify) and listSections order is unspecified, + // so sort by section_ref for a stable hash. + const sections = sortSections(computeSectionRows(siteRef, rawSections, claims)); const pages = sortPages( rawPages.map((p) => ({ site_ref: siteRef, diff --git a/plugins/rw-backend/src/siteIndex/types.ts b/plugins/rw-backend/src/siteIndex/types.ts index a718e67..6385413 100644 --- a/plugins/rw-backend/src/siteIndex/types.ts +++ b/plugins/rw-backend/src/siteIndex/types.ts @@ -6,12 +6,17 @@ export interface SectionOwnershipRow { entity_owner_ref: string | null; } -/** A section registry row (written by the worker from listSections). */ +/** A dense section registry row (one per section, written by the worker from listSections + the + * effective-ownership rollup). `section_path` is owner-relative (the claimer's prefix stripped), + * and `entity_ref`/`entity_owner_ref` are the section's effective owner after nearest-ancestor + * inheritance + site-root sentinel fallback. */ export interface SectionRow { site_ref: string; section_ref: string; section_path: string; parent_section_ref: string | null; + entity_ref: string; + entity_owner_ref: string | null; } /** A page registry row (written by the worker from listPages). */