@rwdocs/backstage-plugin-rw-backend 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/dist/comments/CommentEventPublisher.cjs.js +119 -0
  2. package/dist/comments/CommentEventPublisher.cjs.js.map +1 -0
  3. package/dist/comments/CommentStore.cjs.js +160 -0
  4. package/dist/comments/CommentStore.cjs.js.map +1 -0
  5. package/dist/comments/author.cjs.js +38 -0
  6. package/dist/comments/author.cjs.js.map +1 -0
  7. package/dist/comments/logging.cjs.js +50 -0
  8. package/dist/comments/logging.cjs.js.map +1 -0
  9. package/dist/comments/mapping.cjs.js +32 -0
  10. package/dist/comments/mapping.cjs.js.map +1 -0
  11. package/dist/comments/permissions.cjs.js +24 -0
  12. package/dist/comments/permissions.cjs.js.map +1 -0
  13. package/dist/comments/router.cjs.js +314 -0
  14. package/dist/comments/router.cjs.js.map +1 -0
  15. package/dist/comments/timestamps.cjs.js +23 -0
  16. package/dist/comments/timestamps.cjs.js.map +1 -0
  17. package/dist/comments/types.cjs.js +14 -0
  18. package/dist/comments/types.cjs.js.map +1 -0
  19. package/dist/hub.cjs.js +2 -2
  20. package/dist/hub.cjs.js.map +1 -1
  21. package/dist/inbox/InboxStore.cjs.js +96 -0
  22. package/dist/inbox/InboxStore.cjs.js.map +1 -0
  23. package/dist/inbox/cursor.cjs.js +35 -0
  24. package/dist/inbox/cursor.cjs.js.map +1 -0
  25. package/dist/inbox/inboxRouter.cjs.js +90 -0
  26. package/dist/inbox/inboxRouter.cjs.js.map +1 -0
  27. package/dist/inbox/mapping.cjs.js +35 -0
  28. package/dist/inbox/mapping.cjs.js.map +1 -0
  29. package/dist/inbox/snippet.cjs.js +44 -0
  30. package/dist/inbox/snippet.cjs.js.map +1 -0
  31. package/dist/plugin.cjs.js +129 -34
  32. package/dist/plugin.cjs.js.map +1 -1
  33. package/dist/router.cjs.js +9 -8
  34. package/dist/router.cjs.js.map +1 -1
  35. package/dist/siteIndex/PagesReader.cjs.js +15 -0
  36. package/dist/siteIndex/PagesReader.cjs.js.map +1 -0
  37. package/dist/siteIndex/RegistryStore.cjs.js +20 -0
  38. package/dist/siteIndex/RegistryStore.cjs.js.map +1 -0
  39. package/dist/siteIndex/SectionOwnershipStore.cjs.js +21 -0
  40. package/dist/siteIndex/SectionOwnershipStore.cjs.js.map +1 -0
  41. package/dist/siteIndex/SectionsReader.cjs.js +14 -0
  42. package/dist/siteIndex/SectionsReader.cjs.js.map +1 -0
  43. package/dist/siteIndex/SiteRefreshStore.cjs.js +73 -0
  44. package/dist/siteIndex/SiteRefreshStore.cjs.js.map +1 -0
  45. package/dist/siteIndex/effectiveOwnership.cjs.js +34 -0
  46. package/dist/siteIndex/effectiveOwnership.cjs.js.map +1 -0
  47. package/dist/siteIndex/registryHash.cjs.js +10 -0
  48. package/dist/siteIndex/registryHash.cjs.js.map +1 -0
  49. package/dist/siteIndex/runScan.cjs.js +75 -0
  50. package/dist/siteIndex/runScan.cjs.js.map +1 -0
  51. package/dist/siteIndex/runWorker.cjs.js +81 -0
  52. package/dist/siteIndex/runWorker.cjs.js.map +1 -0
  53. package/dist/siteIndex/schedule.cjs.js +25 -0
  54. package/dist/siteIndex/schedule.cjs.js.map +1 -0
  55. package/migrations/20260621000000_init.js +81 -0
  56. package/package.json +16 -9
  57. package/config.d.ts +0 -56
  58. package/dist/entityPath.cjs.js +0 -12
  59. package/dist/entityPath.cjs.js.map +0 -1
@@ -0,0 +1,14 @@
1
+ 'use strict';
2
+
3
+ const TABLE = "sections";
4
+ class SectionsReader {
5
+ constructor(knex) {
6
+ this.knex = knex;
7
+ }
8
+ async getSection(siteRef, sectionRef) {
9
+ return this.knex(TABLE).where({ site_ref: siteRef, section_ref: sectionRef }).first();
10
+ }
11
+ }
12
+
13
+ exports.SectionsReader = SectionsReader;
14
+ //# sourceMappingURL=SectionsReader.cjs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"SectionsReader.cjs.js","sources":["../../src/siteIndex/SectionsReader.ts"],"sourcesContent":["import type { Knex } from \"knex\";\nimport { SectionRow } from \"./types\";\n\nconst TABLE = \"sections\";\n\n/** By-key reader for the dense `sections` registry. Used by the comment-event publisher\n * to resolve a section's effective owner (`entity_owner_ref`), its owning entity\n * (`entity_ref`), and its owner-relative `section_path` for the deep link. One indexed\n * point-read on PK (site_ref, section_ref); no RwSite load. Kept separate from the\n * swap-only RegistryStore and the by-owner InboxStore reads. */\nexport class SectionsReader {\n constructor(private readonly knex: Knex) {}\n\n async getSection(siteRef: string, sectionRef: string): Promise<SectionRow | undefined> {\n return this.knex<SectionRow>(TABLE)\n .where({ site_ref: siteRef, section_ref: sectionRef })\n .first();\n }\n}\n"],"names":[],"mappings":";;AAGA,MAAM,KAAA,GAAQ,UAAA;AAOP,MAAM,cAAA,CAAe;AAAA,EAC1B,YAA6B,IAAA,EAAY;AAAZ,IAAA,IAAA,CAAA,IAAA,GAAA,IAAA;AAAA,EAAa;AAAA,EAE1C,MAAM,UAAA,CAAW,OAAA,EAAiB,UAAA,EAAqD;AACrF,IAAA,OAAO,IAAA,CAAK,IAAA,CAAiB,KAAK,CAAA,CAC/B,KAAA,CAAM,EAAE,QAAA,EAAU,OAAA,EAAS,WAAA,EAAa,UAAA,EAAY,CAAA,CACpD,KAAA,EAAM;AAAA,EACX;AACF;;;;"}
@@ -0,0 +1,73 @@
1
+ 'use strict';
2
+
3
+ const TABLE = "site_refresh";
4
+ const LOCKING_CLIENTS = ["pg", "mysql", "mysql2"];
5
+ class SiteRefreshStore {
6
+ constructor(knex) {
7
+ this.knex = knex;
8
+ this.useLocking = LOCKING_CLIENTS.includes(String(knex.client.config.client));
9
+ }
10
+ useLocking;
11
+ /** Insert a new queue row (due now) or, on conflict, only advance last_discovery_at. */
12
+ async upsertSite(siteRef, scanStart, executor) {
13
+ const exec = executor ?? this.knex;
14
+ await exec(TABLE).insert({
15
+ site_ref: siteRef,
16
+ next_update_at: scanStart,
17
+ last_built_at: null,
18
+ result_hash: null,
19
+ errors: null,
20
+ last_discovery_at: scanStart
21
+ }).onConflict("site_ref").merge(["last_discovery_at"]);
22
+ }
23
+ /** Delete queue rows not seen in the current (completed) scan. Returns count. */
24
+ async pruneMissing(scanStart) {
25
+ return this.knex(TABLE).where("last_discovery_at", "<", scanStart).del();
26
+ }
27
+ /** Claim up to `batch` due rows, bumping their lease, in one transaction. */
28
+ async claimDue(now, batch, leaseUntil) {
29
+ return this.knex.transaction(async (tx) => {
30
+ const q = tx(TABLE).where("next_update_at", "<=", now).orderBy("next_update_at", "asc").limit(batch).select("site_ref", "result_hash");
31
+ if (this.useLocking) q.forUpdate().skipLocked();
32
+ const rows = await q;
33
+ if (rows.length) {
34
+ await tx(TABLE).whereIn(
35
+ "site_ref",
36
+ rows.map((r) => r.site_ref)
37
+ ).update({ next_update_at: leaseUntil });
38
+ }
39
+ return rows.map((r) => ({ siteRef: r.site_ref, resultHash: r.result_hash ?? null }));
40
+ });
41
+ }
42
+ async completeSuccess(siteRef, resultHash, nextUpdateAt, now) {
43
+ await this.knex(TABLE).where({ site_ref: siteRef }).update({
44
+ last_built_at: now,
45
+ result_hash: resultHash,
46
+ errors: null,
47
+ next_update_at: nextUpdateAt
48
+ });
49
+ }
50
+ async recordError(siteRef, message) {
51
+ await this.knex(TABLE).where({ site_ref: siteRef }).update({ errors: message });
52
+ }
53
+ /** True iff every queue row has been built at least once. Returns true for an empty table
54
+ * (vacuous truth) — prefer anyBuilt() to test readiness. */
55
+ async allBuilt() {
56
+ const [{ count }] = await this.knex(TABLE).whereNull("last_built_at").count({ count: "*" });
57
+ return Number(count) === 0;
58
+ }
59
+ /** True iff at least one queue row has been built. Preferred over allBuilt() for the inbox
60
+ * readiness check: avoids (a) empty-table false-positive and (b) one permanently-failing site
61
+ * blocking the inbox for all users forever. */
62
+ async anyBuilt() {
63
+ const [{ count }] = await this.knex(TABLE).whereNotNull("last_built_at").count({ count: "*" });
64
+ return Number(count) > 0;
65
+ }
66
+ /** Run fn in a single DB transaction (lets a caller atomically span multiple stores). */
67
+ async transaction(fn) {
68
+ return this.knex.transaction(fn);
69
+ }
70
+ }
71
+
72
+ exports.SiteRefreshStore = SiteRefreshStore;
73
+ //# sourceMappingURL=SiteRefreshStore.cjs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"SiteRefreshStore.cjs.js","sources":["../../src/siteIndex/SiteRefreshStore.ts"],"sourcesContent":["import type { Knex } from \"knex\";\nimport type { ClaimedSite } from \"./types\";\n\nconst TABLE = \"site_refresh\";\n// SQLite does not implement FOR UPDATE SKIP LOCKED; only take row locks on clients that support it.\nconst LOCKING_CLIENTS = [\"pg\", \"mysql\", \"mysql2\"];\n\nexport class SiteRefreshStore {\n private readonly useLocking: boolean;\n constructor(private readonly knex: Knex) {\n this.useLocking = LOCKING_CLIENTS.includes(String(knex.client.config.client));\n }\n\n /** Insert a new queue row (due now) or, on conflict, only advance last_discovery_at. */\n async upsertSite(\n siteRef: string,\n scanStart: Date,\n executor?: Knex | Knex.Transaction,\n ): Promise<void> {\n const exec = executor ?? this.knex;\n await exec(TABLE)\n .insert({\n site_ref: siteRef,\n next_update_at: scanStart,\n last_built_at: null,\n result_hash: null,\n errors: null,\n last_discovery_at: scanStart,\n })\n .onConflict(\"site_ref\")\n .merge([\"last_discovery_at\"]);\n }\n\n /** Delete queue rows not seen in the current (completed) scan. Returns count. */\n async pruneMissing(scanStart: Date): Promise<number> {\n return this.knex(TABLE).where(\"last_discovery_at\", \"<\", scanStart).del();\n }\n\n /** Claim up to `batch` due rows, bumping their lease, in one transaction. */\n async claimDue(now: Date, batch: number, leaseUntil: Date): Promise<ClaimedSite[]> {\n return this.knex.transaction(async (tx) => {\n const q = tx(TABLE)\n .where(\"next_update_at\", \"<=\", now)\n .orderBy(\"next_update_at\", \"asc\")\n .limit(batch)\n .select(\"site_ref\", \"result_hash\");\n if (this.useLocking) q.forUpdate().skipLocked();\n const rows = await q;\n if (rows.length) {\n await tx(TABLE)\n .whereIn(\n \"site_ref\",\n rows.map((r) => r.site_ref),\n )\n .update({ next_update_at: leaseUntil });\n }\n return rows.map((r) => ({ siteRef: r.site_ref, resultHash: r.result_hash ?? null }));\n });\n }\n\n async completeSuccess(\n siteRef: string,\n resultHash: string,\n nextUpdateAt: Date,\n now: Date,\n ): Promise<void> {\n await this.knex(TABLE).where({ site_ref: siteRef }).update({\n last_built_at: now,\n result_hash: resultHash,\n errors: null,\n next_update_at: nextUpdateAt,\n });\n }\n\n async recordError(siteRef: string, message: string): Promise<void> {\n await this.knex(TABLE).where({ site_ref: siteRef }).update({ errors: message });\n }\n\n /** True iff every queue row has been built at least once. Returns true for an empty table\n * (vacuous truth) — prefer anyBuilt() to test readiness. */\n async allBuilt(): Promise<boolean> {\n const [{ count }] = await this.knex(TABLE).whereNull(\"last_built_at\").count({ count: \"*\" });\n return Number(count) === 0;\n }\n\n /** True iff at least one queue row has been built. Preferred over allBuilt() for the inbox\n * readiness check: avoids (a) empty-table false-positive and (b) one permanently-failing site\n * blocking the inbox for all users forever. */\n async anyBuilt(): Promise<boolean> {\n const [{ count }] = await this.knex(TABLE).whereNotNull(\"last_built_at\").count({ count: \"*\" });\n return Number(count) > 0;\n }\n\n /** Run fn in a single DB transaction (lets a caller atomically span multiple stores). */\n async transaction<T>(fn: (tx: Knex.Transaction) => Promise<T>): Promise<T> {\n return this.knex.transaction(fn);\n }\n}\n"],"names":[],"mappings":"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"}
@@ -0,0 +1,34 @@
1
+ 'use strict';
2
+
3
+ function computeSectionRows(siteRef, sections, claims) {
4
+ const sentinel = claims.find((c) => c.section_ref === siteRef);
5
+ const realClaims = new Map(
6
+ claims.filter((c) => c.section_ref !== siteRef).map((c) => [c.section_ref, c])
7
+ );
8
+ const pathByRef = new Map(sections.map((s) => [s.sectionRef, s.path]));
9
+ const siteOwnerRef = sentinel?.entity_owner_ref ?? null;
10
+ return sections.map((s) => {
11
+ const claimerRef = [s.sectionRef, ...s.ancestors].find((ref) => realClaims.has(ref));
12
+ const claim = claimerRef ? realClaims.get(claimerRef) : null;
13
+ const entity_ref = claim?.entity_ref ?? siteRef;
14
+ const entity_owner_ref = claim ? claim.entity_owner_ref : siteOwnerRef;
15
+ const claimerPath = claimerRef ? pathByRef.get(claimerRef) ?? "" : "";
16
+ return {
17
+ site_ref: siteRef,
18
+ section_ref: s.sectionRef,
19
+ section_path: stripPrefix(s.path, claimerPath),
20
+ parent_section_ref: s.ancestors[0] ?? null,
21
+ // ancestors is nearest-first; [0] is immediate parent
22
+ entity_ref,
23
+ entity_owner_ref
24
+ };
25
+ });
26
+ }
27
+ function stripPrefix(full, prefix) {
28
+ if (!prefix) return full;
29
+ if (full === prefix) return "";
30
+ return full.startsWith(`${prefix}/`) ? full.slice(prefix.length + 1) : full;
31
+ }
32
+
33
+ exports.computeSectionRows = computeSectionRows;
34
+ //# sourceMappingURL=effectiveOwnership.cjs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"effectiveOwnership.cjs.js","sources":["../../src/siteIndex/effectiveOwnership.ts"],"sourcesContent":["import type { SectionOwnershipRow, SectionRow } from \"./types\";\n\nexport interface RawSection {\n sectionRef: string;\n path: string;\n ancestors: string[]; // nearest-first\n}\n\n/** Build the dense `sections` registry: one row per section carrying both structure\n * (parent_section_ref) and effective ownership. A section is attributed to its nearest claiming\n * ancestor (incl. itself), else the site-root sentinel (section_ref === siteRef), else a\n * null-owner site fallback. The claimer's path is stripped so descendant paths become relative\n * to the owning entity's docs root. */\nexport function computeSectionRows(\n siteRef: string,\n sections: RawSection[],\n claims: SectionOwnershipRow[],\n): SectionRow[] {\n const sentinel = claims.find((c) => c.section_ref === siteRef);\n // Sentinel excluded from realClaims so it doesn't shadow per-section claims;\n // its owner is only the fallback for sections with no more-specific claim.\n const realClaims = new Map(\n claims.filter((c) => c.section_ref !== siteRef).map((c) => [c.section_ref, c]),\n );\n const pathByRef = new Map(sections.map((s) => [s.sectionRef, s.path]));\n const siteOwnerRef = sentinel?.entity_owner_ref ?? null;\n\n return sections.map((s) => {\n const claimerRef = [s.sectionRef, ...s.ancestors].find((ref) => realClaims.has(ref));\n const claim = claimerRef ? realClaims.get(claimerRef)! : null;\n const entity_ref = claim?.entity_ref ?? siteRef;\n const entity_owner_ref = claim ? claim.entity_owner_ref : siteOwnerRef;\n const claimerPath = claimerRef ? (pathByRef.get(claimerRef) ?? \"\") : \"\";\n return {\n site_ref: siteRef,\n section_ref: s.sectionRef,\n section_path: stripPrefix(s.path, claimerPath),\n parent_section_ref: s.ancestors[0] ?? null, // ancestors is nearest-first; [0] is immediate parent\n entity_ref,\n entity_owner_ref,\n };\n });\n}\n\nfunction stripPrefix(full: string, prefix: string): string {\n if (!prefix) return full;\n if (full === prefix) return \"\";\n return full.startsWith(`${prefix}/`) ? full.slice(prefix.length + 1) : full;\n}\n"],"names":[],"mappings":";;AAaO,SAAS,kBAAA,CACd,OAAA,EACA,QAAA,EACA,MAAA,EACc;AACd,EAAA,MAAM,WAAW,MAAA,CAAO,IAAA,CAAK,CAAC,CAAA,KAAM,CAAA,CAAE,gBAAgB,OAAO,CAAA;AAG7D,EAAA,MAAM,aAAa,IAAI,GAAA;AAAA,IACrB,MAAA,CAAO,MAAA,CAAO,CAAC,CAAA,KAAM,EAAE,WAAA,KAAgB,OAAO,CAAA,CAAE,GAAA,CAAI,CAAC,CAAA,KAAM,CAAC,CAAA,CAAE,WAAA,EAAa,CAAC,CAAC;AAAA,GAC/E;AACA,EAAA,MAAM,SAAA,GAAY,IAAI,GAAA,CAAI,QAAA,CAAS,GAAA,CAAI,CAAC,CAAA,KAAM,CAAC,CAAA,CAAE,UAAA,EAAY,CAAA,CAAE,IAAI,CAAC,CAAC,CAAA;AACrE,EAAA,MAAM,YAAA,GAAe,UAAU,gBAAA,IAAoB,IAAA;AAEnD,EAAA,OAAO,QAAA,CAAS,GAAA,CAAI,CAAC,CAAA,KAAM;AACzB,IAAA,MAAM,UAAA,GAAa,CAAC,CAAA,CAAE,UAAA,EAAY,GAAG,CAAA,CAAE,SAAS,CAAA,CAAE,IAAA,CAAK,CAAC,GAAA,KAAQ,UAAA,CAAW,GAAA,CAAI,GAAG,CAAC,CAAA;AACnF,IAAA,MAAM,KAAA,GAAQ,UAAA,GAAa,UAAA,CAAW,GAAA,CAAI,UAAU,CAAA,GAAK,IAAA;AACzD,IAAA,MAAM,UAAA,GAAa,OAAO,UAAA,IAAc,OAAA;AACxC,IAAA,MAAM,gBAAA,GAAmB,KAAA,GAAQ,KAAA,CAAM,gBAAA,GAAmB,YAAA;AAC1D,IAAA,MAAM,cAAc,UAAA,GAAc,SAAA,CAAU,GAAA,CAAI,UAAU,KAAK,EAAA,GAAM,EAAA;AACrE,IAAA,OAAO;AAAA,MACL,QAAA,EAAU,OAAA;AAAA,MACV,aAAa,CAAA,CAAE,UAAA;AAAA,MACf,YAAA,EAAc,WAAA,CAAY,CAAA,CAAE,IAAA,EAAM,WAAW,CAAA;AAAA,MAC7C,kBAAA,EAAoB,CAAA,CAAE,SAAA,CAAU,CAAC,CAAA,IAAK,IAAA;AAAA;AAAA,MACtC,UAAA;AAAA,MACA;AAAA,KACF;AAAA,EACF,CAAC,CAAA;AACH;AAEA,SAAS,WAAA,CAAY,MAAc,MAAA,EAAwB;AACzD,EAAA,IAAI,CAAC,QAAQ,OAAO,IAAA;AACpB,EAAA,IAAI,IAAA,KAAS,QAAQ,OAAO,EAAA;AAC5B,EAAA,OAAO,IAAA,CAAK,UAAA,CAAW,CAAA,EAAG,MAAM,CAAA,CAAA,CAAG,CAAA,GAAI,IAAA,CAAK,KAAA,CAAM,MAAA,CAAO,MAAA,GAAS,CAAC,CAAA,GAAI,IAAA;AACzE;;;;"}
@@ -0,0 +1,10 @@
1
+ 'use strict';
2
+
3
+ var crypto = require('crypto');
4
+
5
+ function registryHash(sections, pages) {
6
+ return crypto.createHash("sha256").update(JSON.stringify({ sections, pages })).digest("hex");
7
+ }
8
+
9
+ exports.registryHash = registryHash;
10
+ //# sourceMappingURL=registryHash.cjs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"registryHash.cjs.js","sources":["../../src/siteIndex/registryHash.ts"],"sourcesContent":["import { createHash } from \"crypto\";\nimport type { SectionRow, PageRow } from \"./types\";\n\n/** Deterministic hash of a site's registries. Both arrays (sections, pages) must be pre-sorted by\n * the caller: JSON.stringify is order-sensitive. `sections` carries effective ownership, so an\n * ownership change flips the hash and triggers a re-swap. */\nexport function registryHash(sections: SectionRow[], pages: PageRow[]): string {\n return createHash(\"sha256\").update(JSON.stringify({ sections, pages })).digest(\"hex\");\n}\n"],"names":["createHash"],"mappings":";;;;AAMO,SAAS,YAAA,CAAa,UAAwB,KAAA,EAA0B;AAC7E,EAAA,OAAOA,iBAAA,CAAW,QAAQ,CAAA,CAAE,MAAA,CAAO,IAAA,CAAK,SAAA,CAAU,EAAE,QAAA,EAAU,KAAA,EAAO,CAAC,CAAA,CAAE,OAAO,KAAK,CAAA;AACtF;;;;"}
@@ -0,0 +1,75 @@
1
+ 'use strict';
2
+
3
+ var catalogModel = require('@backstage/catalog-model');
4
+ var backstagePluginRwCommon = require('@rwdocs/backstage-plugin-rw-common');
5
+
6
+ function ownerOf(entity) {
7
+ const rel = (entity.relations ?? []).find((r) => r.type === catalogModel.RELATION_OWNED_BY);
8
+ return rel?.targetRef ?? null;
9
+ }
10
+ async function runScan(deps) {
11
+ const { catalog, auth, logger, siteConfig, sectionOwnershipStore, siteRefreshStore } = deps;
12
+ const now = deps.now ?? (() => /* @__PURE__ */ new Date());
13
+ const credentials = await auth.getOwnServiceCredentials();
14
+ const onlySiteRef = siteConfig.projectDir && siteConfig.entity ? catalogModel.stringifyEntityRef(catalogModel.parseEntityRef(siteConfig.entity)) : void 0;
15
+ const scanStart = now();
16
+ const linksBySite = /* @__PURE__ */ new Map();
17
+ let completed = false;
18
+ let writeFailed = false;
19
+ try {
20
+ for await (const { entity } of backstagePluginRwCommon.iterateAnnotatedEntities(catalog, credentials)) {
21
+ const selfRef = catalogModel.stringifyEntityRef(catalogModel.getCompoundEntityRef(entity));
22
+ const parsed = backstagePluginRwCommon.parseAnnotation(
23
+ entity.metadata?.annotations?.[backstagePluginRwCommon.RW_ANNOTATION],
24
+ backstagePluginRwCommon.toEntityPath(selfRef)
25
+ );
26
+ if (!parsed) continue;
27
+ const siteRef = parsed.entityRef;
28
+ if (onlySiteRef && siteRef !== onlySiteRef) continue;
29
+ let link;
30
+ if (parsed.sectionRef) {
31
+ link = {
32
+ site_ref: siteRef,
33
+ section_ref: parsed.sectionRef,
34
+ entity_ref: selfRef,
35
+ entity_owner_ref: ownerOf(entity)
36
+ };
37
+ } else if (parsed.entityRef === selfRef) {
38
+ link = {
39
+ site_ref: siteRef,
40
+ section_ref: siteRef,
41
+ entity_ref: selfRef,
42
+ entity_owner_ref: ownerOf(entity)
43
+ };
44
+ }
45
+ if (!link) continue;
46
+ const inner = linksBySite.get(siteRef) ?? /* @__PURE__ */ new Map();
47
+ inner.set(link.section_ref, link);
48
+ linksBySite.set(siteRef, inner);
49
+ }
50
+ completed = true;
51
+ } catch (err) {
52
+ logger.warn(`Site index scan iteration failed; skipping prune: ${err}`);
53
+ }
54
+ for (const [siteRef, inner] of linksBySite) {
55
+ try {
56
+ await siteRefreshStore.transaction(async (tx) => {
57
+ await sectionOwnershipStore.swapSite(siteRef, [...inner.values()], tx);
58
+ await siteRefreshStore.upsertSite(siteRef, scanStart, tx);
59
+ });
60
+ } catch (err) {
61
+ logger.warn(`Site index scan failed for site ${siteRef}: ${err}`);
62
+ writeFailed = true;
63
+ }
64
+ }
65
+ let pruned = 0;
66
+ if (completed && !writeFailed) {
67
+ pruned = await siteRefreshStore.pruneMissing(scanStart);
68
+ }
69
+ logger.info(
70
+ `Site index scan: ${linksBySite.size} site(s) discovered${completed && !writeFailed ? `, ${pruned} stale pruned` : ", prune skipped (incomplete scan)"}`
71
+ );
72
+ }
73
+
74
+ exports.runScan = runScan;
75
+ //# sourceMappingURL=runScan.cjs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"runScan.cjs.js","sources":["../../src/siteIndex/runScan.ts"],"sourcesContent":["import {\n stringifyEntityRef,\n getCompoundEntityRef,\n parseEntityRef,\n RELATION_OWNED_BY,\n type Entity,\n} from \"@backstage/catalog-model\";\nimport type { AuthService, LoggerService } from \"@backstage/backend-plugin-api\";\nimport type { CatalogService } from \"@backstage/plugin-catalog-node\";\nimport {\n iterateAnnotatedEntities,\n parseAnnotation,\n toEntityPath,\n RW_ANNOTATION,\n type RwSiteConfig,\n} from \"@rwdocs/backstage-plugin-rw-common\";\nimport type { SectionOwnershipStore } from \"./SectionOwnershipStore\";\nimport type { SiteRefreshStore } from \"./SiteRefreshStore\";\nimport type { SectionOwnershipRow } from \"./types\";\n\nfunction ownerOf(entity: Entity): string | null {\n const rel = (entity.relations ?? []).find((r) => r.type === RELATION_OWNED_BY);\n return rel?.targetRef ?? null;\n}\n\nexport async function runScan(deps: {\n catalog: Pick<CatalogService, \"queryEntities\">;\n auth: AuthService;\n logger: LoggerService;\n siteConfig: RwSiteConfig;\n sectionOwnershipStore: SectionOwnershipStore;\n siteRefreshStore: SiteRefreshStore;\n now?: () => Date;\n}): Promise<void> {\n const { catalog, auth, logger, siteConfig, sectionOwnershipStore, siteRefreshStore } = deps;\n const now = deps.now ?? (() => new Date());\n const credentials = await auth.getOwnServiceCredentials();\n\n // projectDir mode serves exactly one site; constrain discovery to it.\n const onlySiteRef =\n siteConfig.projectDir && siteConfig.entity\n ? stringifyEntityRef(parseEntityRef(siteConfig.entity))\n : undefined;\n\n const scanStart = now();\n // Dedup per-site links by section_ref (last-claim-wins) to avoid a PK violation on swap.\n const linksBySite = new Map<string, Map<string, SectionOwnershipRow>>();\n let completed = false;\n let writeFailed = false;\n\n try {\n for await (const { entity } of iterateAnnotatedEntities(catalog, credentials)) {\n const selfRef = stringifyEntityRef(getCompoundEntityRef(entity));\n const parsed = parseAnnotation(\n entity.metadata?.annotations?.[RW_ANNOTATION],\n toEntityPath(selfRef),\n );\n if (!parsed) continue;\n const siteRef = parsed.entityRef;\n if (onlySiteRef && siteRef !== onlySiteRef) continue;\n\n let link: SectionOwnershipRow | undefined;\n if (parsed.sectionRef) {\n link = {\n site_ref: siteRef,\n section_ref: parsed.sectionRef,\n entity_ref: selfRef,\n entity_owner_ref: ownerOf(entity),\n };\n } else if (parsed.entityRef === selfRef) {\n // Self-host root claim: no explicit section ref, so use the site ref itself as the\n // section_ref sentinel. At read time, ownership resolution falls back to this sentinel\n // when no more-specific section link is found.\n link = {\n site_ref: siteRef,\n section_ref: siteRef,\n entity_ref: selfRef,\n entity_owner_ref: ownerOf(entity),\n };\n }\n // section-less claim on another site: ignored (matches legacy behavior)\n if (!link) continue;\n\n const inner = linksBySite.get(siteRef) ?? new Map<string, SectionOwnershipRow>();\n inner.set(link.section_ref, link); // last-claim-wins dedup by section_ref\n linksBySite.set(siteRef, inner);\n }\n completed = true;\n } catch (err) {\n logger.warn(`Site index scan iteration failed; skipping prune: ${err}`);\n }\n\n // Per site: swap links + upsert queue row atomically in one transaction.\n for (const [siteRef, inner] of linksBySite) {\n try {\n await siteRefreshStore.transaction(async (tx) => {\n await sectionOwnershipStore.swapSite(siteRef, [...inner.values()], tx);\n await siteRefreshStore.upsertSite(siteRef, scanStart, tx);\n });\n } catch (err) {\n logger.warn(`Site index scan failed for site ${siteRef}: ${err}`);\n // A per-site write failure leaves last_discovery_at un-updated, so\n // pruneMissing would incorrectly delete that still-present site. Mark the scan\n // as dirty so we skip the prune entirely.\n writeFailed = true;\n }\n }\n\n // Prune only after a clean, complete iteration and all per-site writes succeeded —\n // a partial/failed scan must not delete still-present sites from the queue.\n let pruned = 0;\n if (completed && !writeFailed) {\n pruned = await siteRefreshStore.pruneMissing(scanStart);\n }\n\n logger.info(\n `Site index scan: ${linksBySite.size} site(s) discovered${\n completed && !writeFailed ? `, ${pruned} stale pruned` : \", prune skipped (incomplete scan)\"\n }`,\n );\n}\n"],"names":["RELATION_OWNED_BY","stringifyEntityRef","parseEntityRef","iterateAnnotatedEntities","getCompoundEntityRef","parseAnnotation","RW_ANNOTATION","toEntityPath"],"mappings":"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"}
@@ -0,0 +1,81 @@
1
+ 'use strict';
2
+
3
+ var pLimit = require('p-limit');
4
+ var backstagePluginRwCommon = require('@rwdocs/backstage-plugin-rw-common');
5
+ var registryHash = require('./registryHash.cjs.js');
6
+ var effectiveOwnership = require('./effectiveOwnership.cjs.js');
7
+ var schedule = require('./schedule.cjs.js');
8
+
9
+ function _interopDefaultCompat (e) { return e && typeof e === 'object' && 'default' in e ? e : { default: e }; }
10
+
11
+ var pLimit__default = /*#__PURE__*/_interopDefaultCompat(pLimit);
12
+
13
+ function sortSections(rows) {
14
+ return [...rows].sort((a, b) => a.section_ref.localeCompare(b.section_ref));
15
+ }
16
+ function sortPages(rows) {
17
+ return [...rows].sort(
18
+ (a, b) => a.section_ref.localeCompare(b.section_ref) || a.subpath.localeCompare(b.subpath)
19
+ );
20
+ }
21
+ async function runWorker(deps) {
22
+ const { logger, siteRefreshStore, registryStore, sectionOwnershipStore, makeSite } = deps;
23
+ const now = deps.now ?? (() => /* @__PURE__ */ new Date());
24
+ const claimNow = now();
25
+ const claimed = await siteRefreshStore.claimDue(
26
+ claimNow,
27
+ schedule.BATCH_SIZE,
28
+ new Date(claimNow.getTime() + schedule.LEASE_MS)
29
+ );
30
+ if (!claimed.length) return;
31
+ logger.info(`Site index worker: rebuilding ${claimed.length} site(s)`);
32
+ const limit = pLimit__default.default(schedule.CONCURRENCY);
33
+ await Promise.all(
34
+ claimed.map(
35
+ ({ siteRef, resultHash }) => limit(async () => {
36
+ try {
37
+ const site = makeSite(backstagePluginRwCommon.toEntityPath(siteRef));
38
+ const [rawSections, rawPages, claims] = await Promise.all([
39
+ site.listSections(),
40
+ site.listPages(),
41
+ sectionOwnershipStore.listForSite(siteRef)
42
+ ]);
43
+ const sections = sortSections(effectiveOwnership.computeSectionRows(siteRef, rawSections, claims));
44
+ const pages = sortPages(
45
+ rawPages.map((p) => ({
46
+ site_ref: siteRef,
47
+ section_ref: p.sectionRef,
48
+ subpath: p.subpath,
49
+ title: p.title
50
+ }))
51
+ );
52
+ const hash = registryHash.registryHash(sections, pages);
53
+ const changed = hash !== resultHash;
54
+ if (changed) {
55
+ await registryStore.swapSite(siteRef, sections, pages);
56
+ }
57
+ const completedAt = now();
58
+ await siteRefreshStore.completeSuccess(
59
+ siteRef,
60
+ hash,
61
+ schedule.jitteredNextUpdate(completedAt, schedule.INTERVAL_MS, deps.rng),
62
+ completedAt
63
+ );
64
+ logger.debug(
65
+ `Rebuilt ${siteRef}: ${sections.length} sections, ${pages.length} pages${changed ? "" : " (unchanged)"}`
66
+ );
67
+ } catch (err) {
68
+ logger.warn(`Site index rebuild failed for site ${siteRef}: ${err}`);
69
+ try {
70
+ await siteRefreshStore.recordError(siteRef, String(err));
71
+ } catch (recordErr) {
72
+ logger.warn(`Failed to record rebuild error for site ${siteRef}: ${recordErr}`);
73
+ }
74
+ }
75
+ })
76
+ )
77
+ );
78
+ }
79
+
80
+ exports.runWorker = runWorker;
81
+ //# sourceMappingURL=runWorker.cjs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"runWorker.cjs.js","sources":["../../src/siteIndex/runWorker.ts"],"sourcesContent":["import pLimit from \"p-limit\";\nimport type { LoggerService } from \"@backstage/backend-plugin-api\";\nimport { toEntityPath } from \"@rwdocs/backstage-plugin-rw-common\";\nimport type { RwSite } from \"@rwdocs/core\";\nimport type { SiteRefreshStore } from \"./SiteRefreshStore\";\nimport type { RegistryStore } from \"./RegistryStore\";\nimport type { SectionOwnershipStore } from \"./SectionOwnershipStore\";\nimport type { SectionRow, PageRow } from \"./types\";\nimport { registryHash } from \"./registryHash\";\nimport { computeSectionRows } from \"./effectiveOwnership\";\nimport { jitteredNextUpdate, BATCH_SIZE, CONCURRENCY, LEASE_MS, INTERVAL_MS } from \"./schedule\";\n\nfunction sortSections(rows: SectionRow[]): SectionRow[] {\n return [...rows].sort((a, b) => a.section_ref.localeCompare(b.section_ref));\n}\nfunction sortPages(rows: PageRow[]): PageRow[] {\n return [...rows].sort(\n (a, b) => a.section_ref.localeCompare(b.section_ref) || a.subpath.localeCompare(b.subpath),\n );\n}\n\nexport async function runWorker(deps: {\n logger: LoggerService;\n siteRefreshStore: SiteRefreshStore;\n registryStore: RegistryStore;\n sectionOwnershipStore: Pick<SectionOwnershipStore, \"listForSite\">;\n makeSite: (entityPath: string) => Pick<RwSite, \"listSections\" | \"listPages\">;\n now?: () => Date;\n rng?: () => number;\n}): Promise<void> {\n const { logger, siteRefreshStore, registryStore, sectionOwnershipStore, makeSite } = deps;\n const now = deps.now ?? (() => new Date());\n\n const claimNow = now();\n const claimed = await siteRefreshStore.claimDue(\n claimNow,\n BATCH_SIZE,\n new Date(claimNow.getTime() + LEASE_MS),\n );\n if (!claimed.length) return;\n logger.info(`Site index worker: rebuilding ${claimed.length} site(s)`);\n\n const limit = pLimit(CONCURRENCY);\n await Promise.all(\n claimed.map(({ siteRef, resultHash }) =>\n limit(async () => {\n try {\n const site = makeSite(toEntityPath(siteRef));\n // listForSite is independent of the doc-structure reads, so fetch all three together.\n const [rawSections, rawPages, claims] = await Promise.all([\n site.listSections(),\n site.listPages(),\n sectionOwnershipStore.listForSite(siteRef),\n ]);\n // computeSectionRows folds the effective-ownership rollup into each dense section row.\n // registryHash is order-sensitive (JSON.stringify) and listSections order is unspecified,\n // so sort by section_ref for a stable hash.\n const sections = sortSections(computeSectionRows(siteRef, rawSections, claims));\n const pages = sortPages(\n rawPages.map((p) => ({\n site_ref: siteRef,\n section_ref: p.sectionRef,\n subpath: p.subpath,\n title: p.title,\n })),\n );\n const hash = registryHash(sections, pages);\n const changed = hash !== resultHash;\n if (changed) {\n await registryStore.swapSite(siteRef, sections, pages);\n }\n const completedAt = now();\n await siteRefreshStore.completeSuccess(\n siteRef,\n hash,\n jitteredNextUpdate(completedAt, INTERVAL_MS, deps.rng),\n completedAt,\n );\n logger.debug(\n `Rebuilt ${siteRef}: ${sections.length} sections, ${pages.length} pages${\n changed ? \"\" : \" (unchanged)\"\n }`,\n );\n } catch (err) {\n logger.warn(`Site index rebuild failed for site ${siteRef}: ${err}`);\n // recordError is best-effort: if it also throws (e.g. DB down), swallow it so one\n // site's failure never rejects Promise.all and aborts the rest of the batch.\n try {\n await siteRefreshStore.recordError(siteRef, String(err));\n } catch (recordErr) {\n logger.warn(`Failed to record rebuild error for site ${siteRef}: ${recordErr}`);\n }\n }\n }),\n ),\n );\n}\n"],"names":["BATCH_SIZE","LEASE_MS","pLimit","CONCURRENCY","toEntityPath","computeSectionRows","registryHash","jitteredNextUpdate","INTERVAL_MS"],"mappings":"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"}
@@ -0,0 +1,25 @@
1
+ 'use strict';
2
+
3
+ var core = require('@rwdocs/core');
4
+
5
+ const INTERVAL_MS = 15 * 6e4;
6
+ const LEASE_MS = 5 * 6e4;
7
+ const BATCH_SIZE = 10;
8
+ const CONCURRENCY = 4;
9
+ function jitteredNextUpdate(now, intervalMs = INTERVAL_MS, rng = Math.random) {
10
+ return new Date(now.getTime() + intervalMs * (0.5 + rng()));
11
+ }
12
+ function makeSiteFactory(siteConfig) {
13
+ return (entityPath) => siteConfig.projectDir ? core.createSite({ projectDir: siteConfig.projectDir, diagrams: siteConfig.diagrams }) : core.createSite({
14
+ s3: { ...siteConfig.s3, entity: entityPath },
15
+ diagrams: siteConfig.diagrams
16
+ });
17
+ }
18
+
19
+ exports.BATCH_SIZE = BATCH_SIZE;
20
+ exports.CONCURRENCY = CONCURRENCY;
21
+ exports.INTERVAL_MS = INTERVAL_MS;
22
+ exports.LEASE_MS = LEASE_MS;
23
+ exports.jitteredNextUpdate = jitteredNextUpdate;
24
+ exports.makeSiteFactory = makeSiteFactory;
25
+ //# sourceMappingURL=schedule.cjs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"schedule.cjs.js","sources":["../../src/siteIndex/schedule.ts"],"sourcesContent":["import { createSite, type RwSite } from \"@rwdocs/core\";\nimport type { RwSiteConfig } from \"@rwdocs/backstage-plugin-rw-common\";\n\nexport const INTERVAL_MS = 15 * 60_000;\nexport const LEASE_MS = 5 * 60_000;\nexport const BATCH_SIZE = 10;\nexport const CONCURRENCY = 4;\n\n/** now + intervalMs * (0.5 + rng()); rng defaults to Math.random (range [0.5x, 1.5x]). */\nexport function jitteredNextUpdate(\n now: Date,\n intervalMs: number = INTERVAL_MS,\n rng: () => number = Math.random,\n): Date {\n return new Date(now.getTime() + intervalMs * (0.5 + rng()));\n}\n\n/** Build an `RwSite` for an entity path, branching projectDir vs S3 (matches the collator). */\nexport function makeSiteFactory(siteConfig: RwSiteConfig): (entityPath: string) => RwSite {\n return (entityPath: string) =>\n siteConfig.projectDir\n ? createSite({ projectDir: siteConfig.projectDir, diagrams: siteConfig.diagrams })\n : createSite({\n s3: { ...siteConfig.s3!, entity: entityPath },\n diagrams: siteConfig.diagrams,\n });\n}\n"],"names":["createSite"],"mappings":";;;;AAGO,MAAM,cAAc,EAAA,GAAK;AACzB,MAAM,WAAW,CAAA,GAAI;AACrB,MAAM,UAAA,GAAa;AACnB,MAAM,WAAA,GAAc;AAGpB,SAAS,mBACd,GAAA,EACA,UAAA,GAAqB,WAAA,EACrB,GAAA,GAAoB,KAAK,MAAA,EACnB;AACN,EAAA,OAAO,IAAI,KAAK,GAAA,CAAI,OAAA,KAAY,UAAA,IAAc,GAAA,GAAM,KAAI,CAAE,CAAA;AAC5D;AAGO,SAAS,gBAAgB,UAAA,EAA0D;AACxF,EAAA,OAAO,CAAC,UAAA,KACN,UAAA,CAAW,UAAA,GACPA,gBAAW,EAAE,UAAA,EAAY,UAAA,CAAW,UAAA,EAAY,QAAA,EAAU,UAAA,CAAW,QAAA,EAAU,IAC/EA,eAAA,CAAW;AAAA,IACT,IAAI,EAAE,GAAG,UAAA,CAAW,EAAA,EAAK,QAAQ,UAAA,EAAW;AAAA,IAC5C,UAAU,UAAA,CAAW;AAAA,GACtB,CAAA;AACT;;;;;;;;;"}
@@ -0,0 +1,81 @@
1
+ // @ts-check
2
+ /** @param {import('knex').Knex} knex */
3
+ exports.up = async function up(knex) {
4
+ await knex.schema.createTable('comments', table => {
5
+ table.uuid('id').primary(); // uuid v7
6
+ table.uuid('parent_id').nullable();
7
+ table.text('site_ref').notNullable();
8
+ table.text('page_ref').notNullable();
9
+ table.text('section_ref').notNullable();
10
+ table.text('author_ref').notNullable();
11
+ table.text('author_profile').nullable(); // JSON {displayName, picture?}
12
+ table.text('body').notNullable();
13
+ table.text('body_html').notNullable();
14
+ table.text('selectors').notNullable(); // JSON Selector[]
15
+ table.text('status').notNullable(); // 'open' | 'resolved'
16
+ table.dateTime('created_at').notNullable();
17
+ table.dateTime('updated_at').notNullable();
18
+ table.dateTime('resolved_at').nullable();
19
+ table.text('resolved_by').nullable();
20
+ table.dateTime('deleted_at').nullable();
21
+ table.index(['site_ref', 'page_ref'], 'comments_site_page_idx');
22
+ table.index(['site_ref', 'page_ref', 'status'], 'comments_site_page_status_idx');
23
+ table.index(['parent_id'], 'comments_parent_idx');
24
+ table.index(['section_ref'], 'comments_section_idx');
25
+ // The owner inbox lists open top-level threads paged by (updated_at, id) via
26
+ // keyset seek. status leads (equality-filtered to 'open') so the trailing
27
+ // (updated_at, id) supports a range scan for "load more", not a full sort of
28
+ // the owned set.
29
+ table.index(['status', 'updated_at', 'id'], 'comments_status_updated_idx');
30
+ });
31
+
32
+ await knex.schema.createTable('section_ownership', table => {
33
+ table.text('site_ref').notNullable();
34
+ table.text('section_ref').notNullable();
35
+ table.text('entity_ref').notNullable();
36
+ table.text('entity_owner_ref').nullable();
37
+ table.primary(['site_ref', 'section_ref']);
38
+ table.index(['entity_owner_ref'], 'section_ownership_owner_idx');
39
+ });
40
+
41
+ // The `sections` table is dense (one row per section) and carries both structure
42
+ // (parent_section_ref) and the effective-ownership rollup (entity_ref, entity_owner_ref, and an
43
+ // owner-relative section_path). The owner index serves the inbox's hot "sections owned by X" query.
44
+ await knex.schema.createTable('sections', table => {
45
+ table.text('site_ref').notNullable();
46
+ table.text('section_ref').notNullable();
47
+ table.text('section_path').notNullable();
48
+ table.text('parent_section_ref').nullable();
49
+ table.text('entity_ref').notNullable();
50
+ table.text('entity_owner_ref').nullable();
51
+ table.primary(['site_ref', 'section_ref']);
52
+ table.index(['entity_owner_ref'], 'sections_owner_idx');
53
+ });
54
+
55
+ await knex.schema.createTable('pages', table => {
56
+ table.text('site_ref').notNullable();
57
+ table.text('section_ref').notNullable();
58
+ table.text('subpath').notNullable();
59
+ table.text('title').notNullable();
60
+ table.primary(['site_ref', 'section_ref', 'subpath']);
61
+ });
62
+
63
+ await knex.schema.createTable('site_refresh', table => {
64
+ table.text('site_ref').primary();
65
+ table.dateTime('next_update_at').notNullable();
66
+ table.dateTime('last_built_at').nullable();
67
+ table.text('result_hash').nullable();
68
+ table.text('errors').nullable();
69
+ table.dateTime('last_discovery_at').notNullable();
70
+ table.index(['next_update_at'], 'site_refresh_next_update_idx');
71
+ });
72
+ };
73
+
74
+ /** @param {import('knex').Knex} knex */
75
+ exports.down = async function down(knex) {
76
+ await knex.schema.dropTableIfExists('site_refresh');
77
+ await knex.schema.dropTableIfExists('pages');
78
+ await knex.schema.dropTableIfExists('sections');
79
+ await knex.schema.dropTableIfExists('section_ownership');
80
+ await knex.schema.dropTableIfExists('comments');
81
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rwdocs/backstage-plugin-rw-backend",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "license": "MIT OR Apache-2.0",
5
5
  "repository": {
6
6
  "type": "git",
@@ -28,16 +28,16 @@
28
28
  "pluginId": "rw",
29
29
  "pluginPackages": [
30
30
  "@rwdocs/backstage-plugin-rw",
31
- "@rwdocs/backstage-plugin-rw-backend"
31
+ "@rwdocs/backstage-plugin-rw-backend",
32
+ "@rwdocs/backstage-plugin-rw-common"
32
33
  ],
33
34
  "features": {
34
35
  ".": "@backstage/BackendFeature"
35
36
  }
36
37
  },
37
- "configSchema": "config.d.ts",
38
38
  "files": [
39
39
  "dist",
40
- "config.d.ts",
40
+ "migrations",
41
41
  "LICENSE-MIT",
42
42
  "LICENSE-APACHE"
43
43
  ],
@@ -56,25 +56,32 @@
56
56
  "@backstage/catalog-model": "^1.7.6",
57
57
  "@backstage/config": "^1.3.6",
58
58
  "@backstage/errors": "^1.2.7",
59
+ "@backstage/plugin-catalog-node": "^2.2.2",
60
+ "@backstage/plugin-events-node": "^0.4.23",
61
+ "@backstage/plugin-permission-common": "^0.9.9",
62
+ "@backstage/plugin-permission-node": "^0.11.1",
59
63
  "@backstage/types": "^1.2.2",
60
- "@rwdocs/core": "^0.1.21",
64
+ "@rwdocs/backstage-plugin-rw-common": "^0.1.6",
65
+ "@rwdocs/core": "^0.1.28",
61
66
  "express": "^4.21.0",
62
- "express-promise-router": "^4.1.0"
67
+ "express-promise-router": "^4.1.0",
68
+ "luxon": "^3.7.2",
69
+ "p-limit": "^3.1.0",
70
+ "uuid": "^14.0.0",
71
+ "zod": "^3.25.76 || ^4.0.0"
63
72
  },
64
73
  "peerDependencies": {
65
74
  "@backstage/backend-plugin-api": "^1.0.0"
66
75
  },
67
76
  "devDependencies": {
77
+ "@backstage/backend-defaults": "^0.17.3",
68
78
  "@backstage/backend-plugin-api": "^1.0.0",
69
79
  "@backstage/backend-test-utils": "^1.11.0",
70
80
  "@backstage/cli": "^0.36.0",
71
- "@jest/environment-jsdom-abstract": "^30.2.0",
72
81
  "@types/express": "^4.17.0",
73
82
  "@types/jest": "^30.0.0",
74
- "@types/jsdom": "^28",
75
83
  "@types/supertest": "^7.2.0",
76
84
  "jest": "^30.2.0",
77
- "jsdom": "^29.0.0",
78
85
  "prettier": "^3.4.2",
79
86
  "supertest": "^7.2.2",
80
87
  "typescript": "^5.7.0"
package/config.d.ts DELETED
@@ -1,56 +0,0 @@
1
- export interface Config {
2
- /** @visibility backend */
3
- rw?: {
4
- /**
5
- * Local directory containing documentation source files.
6
- * Mutually exclusive with `s3`.
7
- */
8
- projectDir?: string;
9
- /**
10
- * Entity ref that the local projectDir serves as (required when projectDir is set).
11
- * Standard Backstage entity ref format: "kind:namespace/name" (e.g. "component:default/my-docs")
12
- */
13
- entity?: string;
14
- /** Maximum number of cached RwSite instances. Default: 20. */
15
- cacheSize?: number;
16
- /**
17
- * How often to check cached sites for upstream changes and reload if needed.
18
- * If not set, no periodic reloading is performed.
19
- *
20
- * @example
21
- * ```yaml
22
- * rw:
23
- * reloadInterval: { minutes: 5 }
24
- * ```
25
- */
26
- reloadInterval?: import("@backstage/types").HumanDuration;
27
- /**
28
- * S3 storage configuration. Shared across all entity sites.
29
- * Mutually exclusive with `projectDir`.
30
- */
31
- s3?: {
32
- /** S3 bucket name. */
33
- bucket: string;
34
- /** AWS region. */
35
- region?: string;
36
- /** Custom S3 endpoint URL. */
37
- endpoint?: string;
38
- /** Root path within the bucket. */
39
- bucketRootPath?: string;
40
- /** AWS access key ID. */
41
- accessKeyId?: string;
42
- /**
43
- * AWS secret access key.
44
- * @visibility secret
45
- */
46
- secretAccessKey?: string;
47
- };
48
- /** Diagram rendering configuration. Shared across all sites. */
49
- diagrams?: {
50
- /** Kroki server URL for rendering diagrams. */
51
- krokiUrl?: string;
52
- /** Diagram rendering DPI. */
53
- dpi?: number;
54
- };
55
- };
56
- }
@@ -1,12 +0,0 @@
1
- 'use strict';
2
-
3
- var catalogModel = require('@backstage/catalog-model');
4
- require('@backstage/errors');
5
-
6
- function toEntityPath(entityRef) {
7
- const ref = catalogModel.parseEntityRef(entityRef);
8
- return `${ref.namespace}/${ref.kind}/${ref.name}`.toLocaleLowerCase("en-US");
9
- }
10
-
11
- exports.toEntityPath = toEntityPath;
12
- //# sourceMappingURL=entityPath.cjs.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"entityPath.cjs.js","sources":["../src/entityPath.ts"],"sourcesContent":["import { parseEntityRef } from \"@backstage/catalog-model\";\nimport { InputError } from \"@backstage/errors\";\n\n/**\n * Converts a Backstage entity ref (e.g. \"component:default/arch\") to the\n * slash-delimited, lowercased path format used in URLs and cache keys\n * (e.g. \"default/component/arch\").\n *\n * Uses namespace/kind/name ordering to match Backstage catalog URL convention.\n *\n * NOTE: The frontend plugin has a similar utility at\n * plugins/rw/src/components/entityPath.ts — keep in sync if changing logic.\n */\nexport function toEntityPath(entityRef: string): string {\n const ref = parseEntityRef(entityRef);\n return `${ref.namespace}/${ref.kind}/${ref.name}`.toLocaleLowerCase(\"en-US\");\n}\n\n/**\n * Converts a slash-delimited entity path (e.g. \"default/component/arch\")\n * back to the standard Backstage entity ref format (e.g. \"component:default/arch\").\n *\n * This is the inverse of `toEntityPath`. Note that the round-trip always\n * produces lowercased refs since `toEntityPath` lowercases its output.\n */\nexport function fromEntityPath(path: string): string {\n const parts = path.split(\"/\");\n if (parts.length !== 3 || parts.some((p) => !p)) {\n throw new InputError(`Invalid entity path: \"${path}\" (expected \"namespace/kind/name\")`);\n }\n const [namespace, kind, name] = parts;\n return `${kind}:${namespace}/${name}`;\n}\n"],"names":["parseEntityRef"],"mappings":";;;;;AAaO,SAAS,aAAa,SAAA,EAA2B;AACtD,EAAA,MAAM,GAAA,GAAMA,4BAAe,SAAS,CAAA;AACpC,EAAA,OAAO,CAAA,EAAG,GAAA,CAAI,SAAS,CAAA,CAAA,EAAI,GAAA,CAAI,IAAI,CAAA,CAAA,EAAI,GAAA,CAAI,IAAI,CAAA,CAAA,CAAG,iBAAA,CAAkB,OAAO,CAAA;AAC7E;;;;"}