storage.js 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246
  1. import fs from "node:fs/promises";
  2. import path from "node:path";
  3. // Root directory of the NAS share, injected via environment variable.
  4. // On the Linux app server, this is typically `/mnt/niederlassungen`.
  5. // Do NOT cache process.env.NAS_ROOT_PATH at module load time.
  6. // Instead, resolve it on demand so tests (and runtime) can change it.
  7. function getRoot() {
  8. const root = process.env.NAS_ROOT_PATH;
  9. if (!root) {
  10. throw new Error("NAS_ROOT_PATH environment variable is not set");
  11. }
  12. return root;
  13. }
  14. // Build an absolute path below the NAS root from a list of segments.
  15. function fullPath(...segments) {
  16. const root = getRoot();
  17. return path.join(root, ...segments.map(String));
  18. }
  19. // Compare strings that represent numbers in a numeric way.
  20. // This ensures "2" comes before "10" (2 < 10), not after.
  21. function sortNumericStrings(a, b) {
  22. const na = parseInt(a, 10);
  23. const nb = parseInt(b, 10);
  24. if (!Number.isNaN(na) && !Number.isNaN(nb)) {
  25. return na - nb;
  26. }
  27. // Fallback to localeCompare if parsing fails
  28. return a.localeCompare(b, "en");
  29. }
  30. // -----------------------------------------------------------------------------
  31. // RHL-006: Storage micro-cache (process-local TTL cache)
  32. // -----------------------------------------------------------------------------
  33. const TTL_BRANCHES_MS = 60_000;
  34. const TTL_YEARS_MS = 60_000;
  35. const TTL_MONTHS_MS = 15_000;
  36. const TTL_DAYS_MS = 15_000;
  37. const TTL_FILES_MS = 15_000;
  38. // Internal cache store:
  39. // key -> { expiresAt, value } OR { expiresAt, promise }
  40. // - value: resolved cache value
  41. // - promise: in-flight load promise to collapse concurrent reads
  42. const __storageCache = new Map();
  43. /**
  44. * Build a stable cache key for a given listing type.
  45. *
  46. * We include NAS_ROOT_PATH in the key so tests that change the env var do not
  47. * accidentally reuse data from a previous test run.
  48. *
  49. * @param {string} type
  50. * @param {...string} parts
  51. * @returns {string}
  52. */
  53. function buildCacheKey(type, ...parts) {
  54. const root = getRoot();
  55. return [type, root, ...parts.map(String)].join("|");
  56. }
  57. /**
  58. * Generic TTL-cache wrapper.
  59. *
  60. * Behavior:
  61. * 1) If a load is already in-flight (promise exists), reuse it.
  62. * 2) If a cached value exists and is not expired, return it.
  63. * 3) Otherwise run loader(), store the result, and return it.
  64. *
  65. * Failure policy:
  66. * - If loader() throws, the cache entry is removed so later calls can retry.
  67. *
  68. * @template T
  69. * @param {string} key
  70. * @param {number} ttlMs
  71. * @param {() => Promise<T>} loader
  72. * @returns {Promise<T>}
  73. */
  74. async function withTtlCache(key, ttlMs, loader) {
  75. const now = Date.now();
  76. const existing = __storageCache.get(key);
  77. // 1) Collapsing concurrent calls:
  78. if (existing?.promise) {
  79. return existing.promise;
  80. }
  81. // 2) Serve cached values while still fresh:
  82. if (existing && existing.value !== undefined && existing.expiresAt > now) {
  83. return existing.value;
  84. }
  85. // 3) Cache miss or expired: start a new load.
  86. const promise = (async () => {
  87. try {
  88. const value = await loader();
  89. __storageCache.set(key, {
  90. value,
  91. expiresAt: Date.now() + ttlMs,
  92. });
  93. return value;
  94. } catch (err) {
  95. __storageCache.delete(key);
  96. throw err;
  97. }
  98. })();
  99. __storageCache.set(key, {
  100. promise,
  101. expiresAt: now + ttlMs,
  102. });
  103. return promise;
  104. }
  105. /**
  106. * TEST-ONLY helper: clear the micro-cache.
  107. */
  108. export function __clearStorageCacheForTests() {
  109. __storageCache.clear();
  110. }
  111. // -----------------------------------------------------------------------------
  112. // 1. Branches (NL01, NL02, ...)
  113. // -----------------------------------------------------------------------------
  114. export async function listBranches() {
  115. return withTtlCache(buildCacheKey("branches"), TTL_BRANCHES_MS, async () => {
  116. const entries = await fs.readdir(fullPath(), { withFileTypes: true });
  117. return entries
  118. .filter(
  119. (entry) =>
  120. entry.isDirectory() &&
  121. entry.name !== "@Recently-Snapshot" &&
  122. /^NL\d+$/.test(entry.name) // strict: UI expects "NL" uppercase, Linux FS is case-sensitive
  123. )
  124. .map((entry) => entry.name)
  125. .sort((a, b) => sortNumericStrings(a.slice(2), b.slice(2)));
  126. });
  127. }
  128. // -----------------------------------------------------------------------------
  129. // 2. Years (2023, 2024, ...)
  130. // -----------------------------------------------------------------------------
  131. export async function listYears(branch) {
  132. return withTtlCache(
  133. buildCacheKey("years", branch),
  134. TTL_YEARS_MS,
  135. async () => {
  136. const dir = fullPath(branch);
  137. const entries = await fs.readdir(dir, { withFileTypes: true });
  138. return entries
  139. .filter((entry) => entry.isDirectory() && /^\d{4}$/.test(entry.name))
  140. .map((entry) => entry.name)
  141. .sort(sortNumericStrings);
  142. }
  143. );
  144. }
  145. // -----------------------------------------------------------------------------
  146. // 3. Months (01–12)
  147. // -----------------------------------------------------------------------------
  148. export async function listMonths(branch, year) {
  149. return withTtlCache(
  150. buildCacheKey("months", branch, year),
  151. TTL_MONTHS_MS,
  152. async () => {
  153. const dir = fullPath(branch, year);
  154. const entries = await fs.readdir(dir, { withFileTypes: true });
  155. // Important: filter to valid calendar months (1–12) so the UI never gets
  156. // impossible values that would be rejected by route param validation.
  157. return entries
  158. .filter((entry) => entry.isDirectory() && /^\d{1,2}$/.test(entry.name))
  159. .map((entry) => entry.name.trim())
  160. .filter((raw) => {
  161. const n = parseInt(raw, 10);
  162. return Number.isInteger(n) && n >= 1 && n <= 12;
  163. })
  164. .map((raw) => raw.padStart(2, "0"))
  165. .sort(sortNumericStrings);
  166. }
  167. );
  168. }
  169. // -----------------------------------------------------------------------------
  170. // 4. Days (01–31)
  171. // -----------------------------------------------------------------------------
  172. export async function listDays(branch, year, month) {
  173. return withTtlCache(
  174. buildCacheKey("days", branch, year, month),
  175. TTL_DAYS_MS,
  176. async () => {
  177. const dir = fullPath(branch, year, month);
  178. const entries = await fs.readdir(dir, { withFileTypes: true });
  179. // Important: filter to valid calendar days (1–31) to align with UI param validation.
  180. return entries
  181. .filter((entry) => entry.isDirectory() && /^\d{1,2}$/.test(entry.name))
  182. .map((entry) => entry.name.trim())
  183. .filter((raw) => {
  184. const n = parseInt(raw, 10);
  185. return Number.isInteger(n) && n >= 1 && n <= 31;
  186. })
  187. .map((raw) => raw.padStart(2, "0"))
  188. .sort(sortNumericStrings);
  189. }
  190. );
  191. }
  192. // -----------------------------------------------------------------------------
  193. // 5. Files (PDFs) for a given day
  194. // -----------------------------------------------------------------------------
  195. export async function listFiles(branch, year, month, day) {
  196. return withTtlCache(
  197. buildCacheKey("files", branch, year, month, day),
  198. TTL_FILES_MS,
  199. async () => {
  200. const dir = fullPath(branch, year, month, day);
  201. const entries = await fs.readdir(dir);
  202. return entries
  203. .filter((name) => name.toLowerCase().endsWith(".pdf"))
  204. .sort((a, b) => a.localeCompare(b, "en"))
  205. .map((name) => ({
  206. name,
  207. relativePath: `${branch}/${year}/${month}/${day}/${name}`,
  208. }));
  209. }
  210. );
  211. }