Skip to content

Conversation

@pujitm
Copy link
Member

@pujitm pujitm commented Oct 21, 2025

On FAT32, fs.stat() updates accesstime, which means file reads are also writes, which means we can't use usePoll without degrading users' flash drives.

To keep file reads lazy without a larger refactor, I override getters.dynamix() as the entrypoint to re-read the boot drive's dynamix config.

Consecutive calls to getters.dynamix() are a common access pattern, which means we have to memoize to avoid many redundant file reads, so I used a TTL cache with a 250ms lifetime, hoping to scope config files to each request.

getters.dynamix() is also used synchonously, so bit the bullet and switched away from async reads for simplicity, considering that most reads will be occurring from memory, even during cache misses.

Summary by CodeRabbit

  • New Features

    • Added a TTL memoized loader utility with exported types.
    • Added a public function to load Dynamix configuration at startup.
  • Refactor

    • Startup now uses the deterministic, cached config loader; runtime file-watch for Dynamix config removed.
    • Simplified config state handling and load-status reporting for more predictable startup behavior.
  • Tests

    • Added tests for TTL caching, eviction, keying, and conditional caching.
  • Chores

    • Bumped package versions and updated changelog.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Oct 21, 2025

Note

Other AI code review bot(s) detected

CodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review.

Walkthrough

Replaces the async/thunk file-watch loader with a synchronous, TTL-memoized disk loader; adds createTtlMemoizedLoader utility; exports a new store API loadDynamixConfig() which loads/merges config from disk and updates store; removes runtime watcher and updates call sites and tests.

Changes

Cohort / File(s) Change Summary
TTL Memoized Loader
packages/unraid-shared/src/util/create-ttl-memoized-loader.ts, packages/unraid-shared/src/util/__tests__/create-ttl-memoized-loader.test.ts
New TTL memoization utility and tests: keying, TTL eviction, conditional caching, and clear API.
Shared Exports
packages/unraid-shared/src/index.ts
Re-exports createTtlMemoizedLoader and its types.
Disk-backed config loader
api/src/store/actions/load-dynamix-config-file.ts
Replaced async/thunk loader with synchronous loadDynamixConfigFromDiskSync that merges config files via sync I/O and uses TTL memoization.
Store API & lazy loading
api/src/store/index.ts
Added exported loadDynamixConfig() which reads paths['dynamix-config'], uses the disk loader, dispatches updateDynamixConfig with LOADED/FAILED_LOADING, and returns current dynamix state; dynamix getter delegates to it.
Watcher removal
api/src/store/watch/dynamix-config-watch.ts
Removed runtime file-watching and the setupDynamixConfigWatch side effects.
Call sites & slice cleanup
api/src/index.ts, api/src/__test__/core/utils/images/image-file-helpers.test.ts, api/src/unraid-api/app/__test__/app.module.integration.spec.ts, api/src/store/modules/dynamix.ts
Replaced imports/usages of loadDynamixConfigFile with loadDynamixConfig; removed thunk extraReducers and updated tests to call loader directly.
Changelog & manifest/version bumps
.release-please-manifest.json, api/CHANGELOG.md, package.json, api/package.json, packages/*/package.json, plugin/package.json, unraid-ui/package.json, web/package.json, packages/unraid-shared/package.json
Version bumps to 4.25.3, added changelog entry, and updated package manifests/exports/metadata.

Sequence Diagram(s)

sequenceDiagram
    participant Caller as Caller / getter
    participant Store as Store (api/src/store/index.ts)
    participant LoaderAPI as loadDynamixConfig()
    participant DiskLoader as loadDynamixConfigFromDiskSync
    participant TTL as TTL Memoized Loader
    participant Reducer as updateDynamixConfig

    Caller->>Store: access dynamix getter
    Store->>LoaderAPI: invoke loadDynamixConfig()
    LoaderAPI->>Store: read paths['dynamix-config']
    LoaderAPI->>TTL: request cached load for paths
    alt Cache hit (within TTL)
        TTL-->LoaderAPI: return cached merged config
    else Cache miss / expired
        TTL->>DiskLoader: execute sync load & merge files
        DiskLoader-->TTL: return merged config (cached)
    end
    LoaderAPI->>Reducer: dispatch updateDynamixConfig(..., LOADED) or FAILED_LOADING
    LoaderAPI->>Store: return store.getState().dynamix
    Store->>Caller: return dynamix config
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 I hopped from watch to cached delight,
I read the files in quiet night,
TTL keeps fresh within a blink,
No more noisy change-event clink,
Configs merged snug — a rabbit's bite!

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The pull request title "fix: flaky watch on boot drive's dynamix config" is directly related to the main objective and changes in this PR. The changeset removes the setupDynamixConfigWatch runtime file-watching logic and replaces it with a synchronous, TTL-memoized config loader to address FAT32 file system issues where fs.stat() updates access times, causing file reads to be treated as writes. The title accurately summarizes this primary fix—eliminating the flaky file-watching mechanism—without overstating or misleading about the implementation. While the title doesn't mention the new TTL caching strategy, that level of detail is not expected in a concise title.
Docstring Coverage ✅ Passed No functions found in the changes. Docstring coverage check skipped.
✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/config-watch

📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 275e1de and 2f9265c.

📒 Files selected for processing (8)
  • api/package.json (1 hunks)
  • packages/unraid-api-plugin-connect/package.json (1 hunks)
  • packages/unraid-api-plugin-generator/package.json (1 hunks)
  • packages/unraid-api-plugin-health/package.json (1 hunks)
  • packages/unraid-shared/package.json (2 hunks)
  • plugin/package.json (1 hunks)
  • unraid-ui/package.json (1 hunks)
  • web/package.json (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (7)
  • web/package.json
  • packages/unraid-api-plugin-generator/package.json
  • unraid-ui/package.json
  • plugin/package.json
  • packages/unraid-shared/package.json
  • api/package.json
  • packages/unraid-api-plugin-health/package.json
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-06-11T14:14:30.348Z
Learnt from: pujitm
PR: unraid/api#1415
File: plugin/plugins/dynamix.unraid.net.plg:234-236
Timestamp: 2025-06-11T14:14:30.348Z
Learning: For the Unraid Connect plugin, the script `/etc/rc.d/rc.unraid-api` is bundled with the plugin package itself, so its presence on the target system is guaranteed during installation.

Applied to files:

  • packages/unraid-api-plugin-connect/package.json
🔇 Additional comments (1)
packages/unraid-api-plugin-connect/package.json (1)

3-3: Version bump is consistent with coordinated package updates.

The version update from 1.0.0 to 4.25.3 is part of the broader codebase refactor and aligns with the PR's internal changes (replacing async file-watch with synchronous TTL-memoized loader). Verify that this version bump is coordinated across all related package updates in the repository.


Comment @coderabbitai help to get the list of available commands and usage tips.

@pujitm pujitm requested a review from elibosley October 21, 2025 20:12
@codecov
Copy link

codecov bot commented Oct 21, 2025

Codecov Report

❌ Patch coverage is 82.14286% with 10 lines in your changes missing coverage. Please review.
✅ Project coverage is 52.69%. Comparing base (50d8331) to head (2f9265c).
⚠️ Report is 16 commits behind head on main.

Files with missing lines Patch % Lines
api/src/store/index.ts 77.77% 6 Missing ⚠️
api/src/index.ts 0.00% 2 Missing ⚠️
api/src/store/actions/load-dynamix-config-file.ts 92.59% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1753      +/-   ##
==========================================
- Coverage   53.35%   52.69%   -0.67%     
==========================================
  Files         860      865       +5     
  Lines       48275    49343    +1068     
  Branches     4929     4952      +23     
==========================================
+ Hits        25756    26000     +244     
- Misses      22450    23270     +820     
- Partials       69       73       +4     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@pujitm pujitm marked this pull request as ready for review October 21, 2025 20:28
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
api/src/store/index.ts (1)

50-55: Make getters pure by removing side effects from dynamix getter

The review comment correctly identifies that getters.dynamix() has side effects: it calls loadDynamixConfig(), which reads from disk and dispatches store updates (lines 29-47 of api/src/store/index.ts). While TTL caching mitigates repeated disk I/O, multiple sequential accesses in a single method (e.g., customization.service.ts lines 452-457 calls the getter 6 times) demonstrate that callers treat this as a pure selector, not a triggering function.

The suggested refactoring is valid and beneficial: change the getter to return store.getState().dynamix (pure), keeping loadDynamixConfig() as an explicit function. This pattern is already supported by the codebase—loadDynamixConfig() is already called during bootstrap (api/src/index.ts line 77), ensuring data is pre-loaded before services access it. All current usages will continue to work without modification.

🧹 Nitpick comments (10)
packages/unraid-shared/src/util/__tests__/create-ttl-memoized-loader.test.ts (2)

21-37: Safer fake-timers restore

Wrap useFakeTimers() with try/finally to always restore real timers, or move vi.useRealTimers() to an afterEach for this file to avoid leakage across tests.

 it("evicts cache entries once ttl expires", () => {
-    vi.useFakeTimers();
+    vi.useFakeTimers();
     let loadCount = 0;
     // ...
-    vi.useRealTimers();
+    vi.useRealTimers();
 });
+afterEach(() => {
+  vi.useRealTimers();
+});

56-71: Add coverage for edge-cases: clear() and zero TTL

Recommend adding tests to assert:

  • clear() invalidates the cache immediately.
  • ttlMs: 0 results in no reuse even within the same tick (now < expiresAt is false on equality).
api/src/unraid-api/app/__test__/app.module.integration.spec.ts (1)

116-119: Remove noisy console log in integration tests

Drop or gate the debug console.log to keep CI output clean.

-const { getters } = await import('@app/store/index.js');
-console.log('CSRF Token from store:', getters.emhttp().var.csrfToken);
+// Optionally enable when debugging:
+// if (process.env.DEBUG_TESTS) {
+//   const { getters } = await import('@app/store/index.js');
+//   console.log('CSRF Token from store:', getters.emhttp().var.csrfToken);
+// }
api/src/store/index.ts (1)

39-47: Failure path may leave stale config in state

Merging only { status: FAILED_LOADING } can retain previous config keys. Prefer clearing or marking data stale to avoid consumers reading old values with a failure status.

   } catch (error) {
     logger.error(error, 'Failed to load dynamix config from disk');
-    store.dispatch(
-        updateDynamixConfig({
-            status: FileLoadStatus.FAILED_LOADING,
-        })
-    );
+    store.dispatch(
+      updateDynamixConfig({
+        // Option A: minimal
+        status: FileLoadStatus.FAILED_LOADING,
+        // Option B: explicitly clear known sections to avoid stale reads:
+        // display: {} as any, notify: {} as any, ssmtp: {} as any, parity: {} as any, remote: {} as any,
+      })
+    );

Would you like me to scan the slice definition and propose an exact “clear” payload for all top-level keys?

api/src/__test__/core/utils/images/image-file-helpers.test.ts (1)

13-21: Align test init order with app startup

If these assertions depend on paths['dynamix-config'], initialize paths before loading config to avoid future flakiness.

-import { loadDynamixConfig } from '@app/store/index.js';
+import { loadDynamixConfig, store } from '@app/store/index.js';
+import { loadStateFiles } from '@app/store/modules/emhttp.js';
 ...
 test('get banner path returns the banner (state loaded)', async () => {
-  loadDynamixConfig();
+  await store.dispatch(loadStateFiles());
+  loadDynamixConfig();
   await expect(getBannerPathIfPresent()).resolves.toContain('/dev/dynamix/banner.png');
 });
 
 test('get banner path returns null when no banner (state loaded)', async () => {
-  loadDynamixConfig();
+  await store.dispatch(loadStateFiles());
+  loadDynamixConfig();
   await expect(getBannerPathIfPresent('notabanner.png')).resolves.toMatchInlineSnapshot('null');
 });

As per coding guidelines.

api/src/store/actions/load-dynamix-config-file.ts (3)

33-37: Stabilize cache key to avoid churn from undefined/null entries

JSON.stringify(configPaths) treats undefined as null in arrays and includes them, generating different keys for semantically equivalent inputs. Filter first to only valid strings when deriving the key.

-  getCacheKey: (configPaths: ConfigPaths): string => JSON.stringify(configPaths),
+  getCacheKey: (configPaths: ConfigPaths): string =>
+    JSON.stringify(configPaths.filter((p): p is string => Boolean(p))),

41-47: Shallow merge may drop nested config keys

{ ...accumulator, ...configFile } overwrites whole top-level sections. If multiple files provide different nested keys under the same section, earlier keys are lost. Consider a deep merge.

-        return configFiles.reduce<RecursivePartial<DynamixConfig>>(
-            (accumulator, configFile) => ({
-                ...accumulator,
-                ...configFile,
-            }),
-            {}
-        );
+        const deepMerge = <T extends Record<string, any>>(a: T, b: T): T => {
+          const out: any = { ...a };
+          for (const [k, v] of Object.entries(b)) {
+            if (v && typeof v === 'object' && !Array.isArray(v) && typeof out[k] === 'object') {
+              out[k] = deepMerge(out[k], v);
+            } else {
+              out[k] = v;
+            }
+          }
+          return out;
+        };
+        return configFiles.reduce<RecursivePartial<DynamixConfig>>(
+          (acc, cfg) => deepMerge(acc, cfg),
+          {}
+        );

If lodash.merge is already a dependency, we can switch to it instead to reduce code.


28-49: Expose a cache clear for tests and manual refresh

Providing a small export to call memoizedConfigLoader.clear() helps tests and admin endpoints force a reload after on-disk changes.

 const memoizedConfigLoader = createTtlMemoizedLoader<
   RecursivePartial<DynamixConfig>,
   ConfigPaths,
   string
 >({
   // ...
 });
 
+export const clearDynamixConfigCache = () => memoizedConfigLoader.clear();
api/src/index.ts (1)

79-83: Without a watcher, consider a refresh strategy

Since the dynamix watch is removed, runtime changes won’t apply unless loadDynamixConfig() is called again. Options:

  • Periodic refresh with backoff (e.g., every few minutes).
  • Admin endpoint/command to call loadDynamixConfig() (and clear memoizer).
  • Triggered reload when relevant GraphQL resolvers need fresh values.
packages/unraid-shared/src/util/create-ttl-memoized-loader.ts (1)

58-66: Single-entry cache can surprise callers using multiple keys

Current design holds only one cached entry; switching keys evicts the previous key’s value. If “independent entries” are desired, use a per-key Map while keeping the no-key case fast.

-export const createTtlMemoizedLoader = <T, Args = void, Key = Args>(
+export const createTtlMemoizedLoader = <T, Args = void, Key = Args>(
   options: CreateTtlMemoizedLoaderOptions<T, Args, Key>
 ): TtlMemoizedLoader<T, Args> => {
-  let cache: CacheEntry<T, Key> | undefined;
+  const NO_KEY: unique symbol = Symbol('NO_KEY');
+  const cache = new Map<unknown, CacheEntry<T, Key>>();
 
   const clear = () => {
-    cache = undefined;
+    cache.clear();
   };
 
   const get = (args: Args): T => {
     const now = Date.now();
-    const key = options.getCacheKey?.(args);
+    const key = options.getCacheKey ? options.getCacheKey(args) : NO_KEY;
 
-    if (cache) {
-      const keyMatches =
-        options.getCacheKey === undefined ? true : Object.is(cache.key, key);
-
-      if (keyMatches && now < cache.expiresAt) {
-        return cache.value;
-      }
-    }
+    const entry = cache.get(key as any);
+    if (entry && now < entry.expiresAt) {
+      return entry.value;
+    }
 
     const value = options.load(args);
     if (!options.shouldCache || options.shouldCache(value)) {
-      cache = {
+      const newEntry: CacheEntry<T, Key> = {
         value,
         expiresAt: now + Math.max(0, options.ttlMs),
         key,
       };
+      cache.set(key as any, newEntry);
     } else {
-      cache = undefined;
+      cache.delete(key as any);
     }
 
     return value;
   };
 
   return { get, clear };
 };

Note: This trades a tiny memory increase (per live key within TTL) for clearer semantics. If capacity limits are needed, we can add an optional max size and LRU eviction.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8c4e9dd and ec7aa06.

📒 Files selected for processing (10)
  • api/src/__test__/core/utils/images/image-file-helpers.test.ts (1 hunks)
  • api/src/index.ts (2 hunks)
  • api/src/store/actions/load-dynamix-config-file.ts (2 hunks)
  • api/src/store/index.ts (2 hunks)
  • api/src/store/modules/dynamix.ts (0 hunks)
  • api/src/store/watch/dynamix-config-watch.ts (0 hunks)
  • api/src/unraid-api/app/__test__/app.module.integration.spec.ts (2 hunks)
  • packages/unraid-shared/src/index.ts (1 hunks)
  • packages/unraid-shared/src/util/__tests__/create-ttl-memoized-loader.test.ts (1 hunks)
  • packages/unraid-shared/src/util/create-ttl-memoized-loader.ts (1 hunks)
💤 Files with no reviewable changes (2)
  • api/src/store/watch/dynamix-config-watch.ts
  • api/src/store/modules/dynamix.ts
🧰 Additional context used
📓 Path-based instructions (8)
**/{store,stores}/**/*.ts

📄 CodeRabbit inference engine (.cursor/rules/web-testing-rules.mdc)

In store files, explicitly import Vue reactivity utilities (e.g., computed, ref, watchEffect); do not rely on Nuxt auto-imports

Files:

  • api/src/store/index.ts
  • api/src/store/actions/load-dynamix-config-file.ts
**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.{ts,tsx}: Use TypeScript import specifiers with .js extensions for ESM compatibility
Never use the any type; prefer precise typing
Avoid type casting; model proper types from the start

Files:

  • api/src/store/index.ts
  • api/src/store/actions/load-dynamix-config-file.ts
  • api/src/__test__/core/utils/images/image-file-helpers.test.ts
  • packages/unraid-shared/src/util/create-ttl-memoized-loader.ts
  • packages/unraid-shared/src/index.ts
  • packages/unraid-shared/src/util/__tests__/create-ttl-memoized-loader.test.ts
  • api/src/unraid-api/app/__test__/app.module.integration.spec.ts
  • api/src/index.ts
api/**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

cache-manager v7 TTL values must be in milliseconds (e.g., 600000 for 10 minutes)

Files:

  • api/src/store/index.ts
  • api/src/store/actions/load-dynamix-config-file.ts
  • api/src/__test__/core/utils/images/image-file-helpers.test.ts
  • api/src/unraid-api/app/__test__/app.module.integration.spec.ts
  • api/src/index.ts
api/**/*.{test,spec}.{js,jsx,ts,tsx}

📄 CodeRabbit inference engine (.cursor/rules/api-rules.mdc)

api/**/*.{test,spec}.{js,jsx,ts,tsx}: Use Vitest for tests in the api; do not use Jest
Prefer not to mock simple dependencies in tests
For error testing, use .rejects.toThrow() without arguments; avoid asserting exact error messages unless the message format is the subject under test

Files:

  • api/src/__test__/core/utils/images/image-file-helpers.test.ts
  • api/src/unraid-api/app/__test__/app.module.integration.spec.ts
{**/*.test.ts,**/__test__/{components,store}/**/*.ts}

📄 CodeRabbit inference engine (.cursor/rules/web-testing-rules.mdc)

{**/*.test.ts,**/__test__/{components,store}/**/*.ts}: Use .rejects.toThrow() without arguments when asserting that async functions throw; avoid checking exact error message strings unless the message format is explicitly under test
Focus tests on observable behavior and outcomes, not implementation details such as exact error messages
Use await nextTick() for DOM update assertions and flushPromises() for complex async chains; always await async operations before asserting
Place module mock declarations (vi.mock) at the top level of the test file to avoid hoisting issues
Use factory functions in vi.mock calls to define mocks and avoid hoisting pitfalls
Use vi.spyOn() to specify return values or behavior of methods under test
Reset/clear mocks between tests using vi.clearAllMocks() (and vi.resetAllMocks() when appropriate) to ensure isolation
Do not rely on Nuxt auto-imports in tests; import required Vue utilities explicitly in test files
Remember that vi.mock calls are hoisted; avoid mixing mock declarations and module mocks incorrectly

Files:

  • api/src/__test__/core/utils/images/image-file-helpers.test.ts
  • packages/unraid-shared/src/util/__tests__/create-ttl-memoized-loader.test.ts
api/**/*.{test,spec}.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

api/**/*.{test,spec}.{ts,tsx}: API test suite is Vitest; do not use Jest
Prefer not to mock simple dependencies in API tests

Files:

  • api/src/__test__/core/utils/images/image-file-helpers.test.ts
  • api/src/unraid-api/app/__test__/app.module.integration.spec.ts
{api,web}/**/*.{test,spec}.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

{api,web}/**/*.{test,spec}.{ts,tsx}: For error tests, use .rejects.toThrow() without arguments; avoid asserting exact error messages unless that format is the subject
Focus tests on behavior, not implementation details
Avoid brittle tests tied to exact error or log wording
Use mocks as nouns, not verbs
Always await async operations before making assertions
Place all mock declarations at the top level; use factory functions for module mocks; clear mocks between tests

Files:

  • api/src/__test__/core/utils/images/image-file-helpers.test.ts
  • api/src/unraid-api/app/__test__/app.module.integration.spec.ts
api/src/unraid-api/**

📄 CodeRabbit inference engine (.cursor/rules/api-rules.mdc)

Prefer adding new files to the Nest repo at api/src/unraid-api/ instead of legacy code

Files:

  • api/src/unraid-api/app/__test__/app.module.integration.spec.ts
🧠 Learnings (3)
📚 Learning: 2025-01-22T18:34:06.925Z
Learnt from: elibosley
PR: unraid/api#1068
File: api/src/unraid-api/auth/api-key.service.ts:122-137
Timestamp: 2025-01-22T18:34:06.925Z
Learning: The store in app/store is implemented using Redux's configureStore, where dispatch operations for config updates are synchronous in-memory state updates that cannot fail, making transaction-like patterns unnecessary.

Applied to files:

  • api/src/store/index.ts
📚 Learning: 2025-08-11T15:10:28.150Z
Learnt from: CR
PR: unraid/api#0
File: .cursor/rules/web-testing-rules.mdc:0-0
Timestamp: 2025-08-11T15:10:28.150Z
Learning: This Nuxt app is tested with vitest outside the Nuxt runtime; structure tests accordingly

Applied to files:

  • api/src/unraid-api/app/__test__/app.module.integration.spec.ts
📚 Learning: 2025-09-12T01:36:59.838Z
Learnt from: CR
PR: unraid/api#0
File: CLAUDE.md:0-0
Timestamp: 2025-09-12T01:36:59.838Z
Learning: Applies to api/**/*.{test,spec}.{ts,tsx} : API test suite is Vitest; do not use Jest

Applied to files:

  • api/src/unraid-api/app/__test__/app.module.integration.spec.ts
🧬 Code graph analysis (7)
api/src/store/index.ts (3)
api/src/store/actions/load-dynamix-config-file.ts (1)
  • loadDynamixConfigFromDiskSync (57-61)
api/src/store/modules/dynamix.ts (1)
  • updateDynamixConfig (20-22)
api/src/core/log.ts (1)
  • logger (50-80)
api/src/store/actions/load-dynamix-config-file.ts (4)
packages/unraid-shared/src/util/file.ts (1)
  • fileExistsSync (22-29)
api/src/core/utils/misc/parse-config.ts (1)
  • parseConfig (146-203)
packages/unraid-shared/src/util/create-ttl-memoized-loader.ts (1)
  • createTtlMemoizedLoader (45-82)
api/src/core/types/ini.ts (1)
  • DynamixConfig (126-132)
api/src/__test__/core/utils/images/image-file-helpers.test.ts (1)
api/src/store/index.ts (1)
  • loadDynamixConfig (29-48)
packages/unraid-shared/src/util/create-ttl-memoized-loader.ts (1)
packages/unraid-shared/src/index.ts (3)
  • CreateTtlMemoizedLoaderOptions (9-9)
  • TtlMemoizedLoader (10-10)
  • createTtlMemoizedLoader (7-7)
packages/unraid-shared/src/util/__tests__/create-ttl-memoized-loader.test.ts (2)
packages/unraid-shared/src/index.ts (1)
  • createTtlMemoizedLoader (7-7)
packages/unraid-shared/src/util/create-ttl-memoized-loader.ts (1)
  • createTtlMemoizedLoader (45-82)
api/src/unraid-api/app/__test__/app.module.integration.spec.ts (1)
api/src/store/index.ts (1)
  • loadDynamixConfig (29-48)
api/src/index.ts (1)
api/src/store/index.ts (1)
  • loadDynamixConfig (29-48)
🔇 Additional comments (2)
packages/unraid-shared/src/index.ts (1)

7-11: LGTM — public surface re-export looks correct

ESM .js specifiers used; types re-exported with export type to avoid runtime impact.

api/src/index.ts (1)

76-78: LGTM — startup now uses direct, synchronous config load

Order matches: state files → registration → dynamix config. This aligns with the new loader.

Comment on lines +39 to +54
it("treats different cache keys as independent entries", () => {
let loadCount = 0;
const loader = createTtlMemoizedLoader<number, { key: string }>({
ttlMs: 500,
getCacheKey: ({ key }) => key,
load: ({ key }) => {
loadCount += 1;
return Number(`${loadCount}${key.length}`);
},
});

expect(loader.get({ key: "a" })).toBe(11);
expect(loader.get({ key: "a" })).toBe(11);
expect(loader.get({ key: "ab" })).toBe(22);
expect(loadCount).toBe(2);
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Test name suggests per-key caching that the implementation doesn’t provide

The memoizer currently caches only the last key, not independent entries per key. Calling get({ key: 'a' }) again after get({ key: 'ab' }) would recompute. Either:

  • Adjust the test name/expectations, or
  • Update the implementation to cache per key (Map) and add an assertion for revisiting the first key.
🤖 Prompt for AI Agents
In packages/unraid-shared/src/util/__tests__/create-ttl-memoized-loader.test.ts
around lines 39–54, the test is written to assert per-key caching but the
implementation currently only remembers the last key; update either the test or
the implementation — to preserve the test intent, change the memoizer to use a
Map keyed by getCacheKey that stores {value, expiry} per key, have get check the
map for an unexpired entry and call load only when missing/expired, and then add
an assertion that revisiting the first key after loading a second key returns
the cached value and does not increment loadCount; alternatively, if you intend
single-entry caching, rename the test and adjust expectations to reflect
last-key-only behavior.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (4)
packages/unraid-shared/src/util/create-ttl-memoized-loader.ts (1)

27-36: Clarify single-slot semantics; minor API ergonomics.

  • Doc says “entry” but not that it’s single-slot; note that a new key evicts the previous one to avoid misuse expectations.
  • Consider making get friendlier for Args = void via tuple conditional args:
    [ suggest_optional_refactor ]
-export type TtlMemoizedLoader<T, Args = void> = {
-  get: (args: Args) => T;
+export type TtlMemoizedLoader<T, Args = void> = {
+  get: (...args: Args extends void ? [] : [Args]) => T;
  • Recommend documenting that getCacheKey should return a stable primitive (e.g., string) since Object.is compares by reference for objects.

Also applies to: 45-49, 58-66

api/src/__test__/core/utils/images/image-file-helpers.test.ts (1)

7-7: Direct loader use is fine; optionally centralize init.

Calling loadDynamixConfig() synchronously per test is OK. Consider moving it to a beforeAll to reduce duplication.

Also applies to: 14-14, 19-19

api/src/store/actions/load-dynamix-config-file.ts (1)

16-23: Sync loader is clean; consider deep merge and per-file resilience.

  • Current merge is shallow; nested sections (e.g., display, notify) from later files overwrite entire objects. If prior behavior expected key-level overlay, switch to deep merge.
-        return configFiles.reduce<RecursivePartial<DynamixConfig>>(
-            (accumulator, configFile) => ({
-                ...accumulator,
-                ...configFile,
-            }),
-            {}
-        );
+        return configFiles.reduce<RecursivePartial<DynamixConfig>>((acc, cfg) => deepMerge(acc, cfg), {});

Add a small deepMerge utility (non-mutating) locally or reuse an existing helper if present.

  • Optional: continue loading remaining files when one parse fails (catch inside loadConfigFileSync or within the reducer) to avoid one corrupt file breaking the load; still let the caller set FAILED status if all fail.

  • Nit: reuse the ConfigPaths alias in the exported function signature for consistency.

Would you like a PR-ready deepMerge helper added under core/utils?

Also applies to: 25-49, 57-61

api/src/store/index.ts (1)

22-48: Consider adding explicit return type annotation.

The function implementation correctly handles both success and error cases, and the TTL-cached loading approach appropriately addresses the FAT32 file system issue described in the PR objectives. However, adding an explicit return type would improve code clarity and maintainability.

Apply this diff to add the return type:

 /**
  * Loads the dynamix config into the store.
  * Can be called multiple times - uses TTL caching internally.
  * @returns The loaded dynamix config.
  */
-export const loadDynamixConfig = () => {
+export const loadDynamixConfig = (): RootState['dynamix'] => {
     const configPaths = store.getState().paths['dynamix-config'] ?? [];
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8c4e9dd and ec7aa06.

📒 Files selected for processing (10)
  • api/src/__test__/core/utils/images/image-file-helpers.test.ts (1 hunks)
  • api/src/index.ts (2 hunks)
  • api/src/store/actions/load-dynamix-config-file.ts (2 hunks)
  • api/src/store/index.ts (2 hunks)
  • api/src/store/modules/dynamix.ts (0 hunks)
  • api/src/store/watch/dynamix-config-watch.ts (0 hunks)
  • api/src/unraid-api/app/__test__/app.module.integration.spec.ts (2 hunks)
  • packages/unraid-shared/src/index.ts (1 hunks)
  • packages/unraid-shared/src/util/__tests__/create-ttl-memoized-loader.test.ts (1 hunks)
  • packages/unraid-shared/src/util/create-ttl-memoized-loader.ts (1 hunks)
💤 Files with no reviewable changes (2)
  • api/src/store/modules/dynamix.ts
  • api/src/store/watch/dynamix-config-watch.ts
🧰 Additional context used
📓 Path-based instructions (8)
{**/*.test.ts,**/__test__/{components,store}/**/*.ts}

📄 CodeRabbit inference engine (.cursor/rules/web-testing-rules.mdc)

{**/*.test.ts,**/__test__/{components,store}/**/*.ts}: Use .rejects.toThrow() without arguments when asserting that async functions throw; avoid checking exact error message strings unless the message format is explicitly under test
Focus tests on observable behavior and outcomes, not implementation details such as exact error messages
Use await nextTick() for DOM update assertions and flushPromises() for complex async chains; always await async operations before asserting
Place module mock declarations (vi.mock) at the top level of the test file to avoid hoisting issues
Use factory functions in vi.mock calls to define mocks and avoid hoisting pitfalls
Use vi.spyOn() to specify return values or behavior of methods under test
Reset/clear mocks between tests using vi.clearAllMocks() (and vi.resetAllMocks() when appropriate) to ensure isolation
Do not rely on Nuxt auto-imports in tests; import required Vue utilities explicitly in test files
Remember that vi.mock calls are hoisted; avoid mixing mock declarations and module mocks incorrectly

Files:

  • packages/unraid-shared/src/util/__tests__/create-ttl-memoized-loader.test.ts
  • api/src/__test__/core/utils/images/image-file-helpers.test.ts
**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.{ts,tsx}: Use TypeScript import specifiers with .js extensions for ESM compatibility
Never use the any type; prefer precise typing
Avoid type casting; model proper types from the start

Files:

  • packages/unraid-shared/src/util/__tests__/create-ttl-memoized-loader.test.ts
  • packages/unraid-shared/src/index.ts
  • packages/unraid-shared/src/util/create-ttl-memoized-loader.ts
  • api/src/unraid-api/app/__test__/app.module.integration.spec.ts
  • api/src/store/index.ts
  • api/src/store/actions/load-dynamix-config-file.ts
  • api/src/__test__/core/utils/images/image-file-helpers.test.ts
  • api/src/index.ts
api/src/unraid-api/**

📄 CodeRabbit inference engine (.cursor/rules/api-rules.mdc)

Prefer adding new files to the Nest repo at api/src/unraid-api/ instead of legacy code

Files:

  • api/src/unraid-api/app/__test__/app.module.integration.spec.ts
api/**/*.{test,spec}.{js,jsx,ts,tsx}

📄 CodeRabbit inference engine (.cursor/rules/api-rules.mdc)

api/**/*.{test,spec}.{js,jsx,ts,tsx}: Use Vitest for tests in the api; do not use Jest
Prefer not to mock simple dependencies in tests
For error testing, use .rejects.toThrow() without arguments; avoid asserting exact error messages unless the message format is the subject under test

Files:

  • api/src/unraid-api/app/__test__/app.module.integration.spec.ts
  • api/src/__test__/core/utils/images/image-file-helpers.test.ts
api/**/*.{test,spec}.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

api/**/*.{test,spec}.{ts,tsx}: API test suite is Vitest; do not use Jest
Prefer not to mock simple dependencies in API tests

Files:

  • api/src/unraid-api/app/__test__/app.module.integration.spec.ts
  • api/src/__test__/core/utils/images/image-file-helpers.test.ts
{api,web}/**/*.{test,spec}.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

{api,web}/**/*.{test,spec}.{ts,tsx}: For error tests, use .rejects.toThrow() without arguments; avoid asserting exact error messages unless that format is the subject
Focus tests on behavior, not implementation details
Avoid brittle tests tied to exact error or log wording
Use mocks as nouns, not verbs
Always await async operations before making assertions
Place all mock declarations at the top level; use factory functions for module mocks; clear mocks between tests

Files:

  • api/src/unraid-api/app/__test__/app.module.integration.spec.ts
  • api/src/__test__/core/utils/images/image-file-helpers.test.ts
api/**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

cache-manager v7 TTL values must be in milliseconds (e.g., 600000 for 10 minutes)

Files:

  • api/src/unraid-api/app/__test__/app.module.integration.spec.ts
  • api/src/store/index.ts
  • api/src/store/actions/load-dynamix-config-file.ts
  • api/src/__test__/core/utils/images/image-file-helpers.test.ts
  • api/src/index.ts
**/{store,stores}/**/*.ts

📄 CodeRabbit inference engine (.cursor/rules/web-testing-rules.mdc)

In store files, explicitly import Vue reactivity utilities (e.g., computed, ref, watchEffect); do not rely on Nuxt auto-imports

Files:

  • api/src/store/index.ts
  • api/src/store/actions/load-dynamix-config-file.ts
🧠 Learnings (3)
📚 Learning: 2025-08-11T15:10:28.150Z
Learnt from: CR
PR: unraid/api#0
File: .cursor/rules/web-testing-rules.mdc:0-0
Timestamp: 2025-08-11T15:10:28.150Z
Learning: This Nuxt app is tested with vitest outside the Nuxt runtime; structure tests accordingly

Applied to files:

  • api/src/unraid-api/app/__test__/app.module.integration.spec.ts
📚 Learning: 2025-09-12T01:36:59.838Z
Learnt from: CR
PR: unraid/api#0
File: CLAUDE.md:0-0
Timestamp: 2025-09-12T01:36:59.838Z
Learning: Applies to api/**/*.{test,spec}.{ts,tsx} : API test suite is Vitest; do not use Jest

Applied to files:

  • api/src/unraid-api/app/__test__/app.module.integration.spec.ts
📚 Learning: 2025-01-22T18:34:06.925Z
Learnt from: elibosley
PR: unraid/api#1068
File: api/src/unraid-api/auth/api-key.service.ts:122-137
Timestamp: 2025-01-22T18:34:06.925Z
Learning: The store in app/store is implemented using Redux's configureStore, where dispatch operations for config updates are synchronous in-memory state updates that cannot fail, making transaction-like patterns unnecessary.

Applied to files:

  • api/src/store/index.ts
🧬 Code graph analysis (7)
packages/unraid-shared/src/util/__tests__/create-ttl-memoized-loader.test.ts (2)
packages/unraid-shared/src/index.ts (1)
  • createTtlMemoizedLoader (7-7)
packages/unraid-shared/src/util/create-ttl-memoized-loader.ts (1)
  • createTtlMemoizedLoader (45-82)
packages/unraid-shared/src/util/create-ttl-memoized-loader.ts (1)
packages/unraid-shared/src/index.ts (3)
  • CreateTtlMemoizedLoaderOptions (9-9)
  • TtlMemoizedLoader (10-10)
  • createTtlMemoizedLoader (7-7)
api/src/unraid-api/app/__test__/app.module.integration.spec.ts (1)
api/src/store/index.ts (1)
  • loadDynamixConfig (29-48)
api/src/store/index.ts (3)
api/src/store/actions/load-dynamix-config-file.ts (1)
  • loadDynamixConfigFromDiskSync (57-61)
api/src/store/modules/dynamix.ts (1)
  • updateDynamixConfig (20-22)
api/src/core/log.ts (1)
  • logger (50-80)
api/src/store/actions/load-dynamix-config-file.ts (4)
packages/unraid-shared/src/util/file.ts (1)
  • fileExistsSync (22-29)
api/src/core/utils/misc/parse-config.ts (1)
  • parseConfig (146-203)
packages/unraid-shared/src/util/create-ttl-memoized-loader.ts (1)
  • createTtlMemoizedLoader (45-82)
api/src/core/types/ini.ts (1)
  • DynamixConfig (126-132)
api/src/__test__/core/utils/images/image-file-helpers.test.ts (1)
api/src/store/index.ts (1)
  • loadDynamixConfig (29-48)
api/src/index.ts (1)
api/src/store/index.ts (1)
  • loadDynamixConfig (29-48)
🔇 Additional comments (7)
api/src/index.ts (2)

77-77: Direct, synchronous config load on startup LGTM.

Keeps reads scoped and avoids watch-induced atime bumps. No await needed since loader is sync.


22-22: Public API import verified—no lingering references to old thunk.

Verification confirms loadDynamixConfigFile has no remaining usages in the codebase. The import at line 22 correctly uses loadDynamixConfig with ESM .js extension.

packages/unraid-shared/src/util/__tests__/create-ttl-memoized-loader.test.ts (1)

1-72: Solid coverage and ESM-correct imports.

Tests cover TTL reuse, expiry, keyed invalidation, and conditional caching. Nice use of useFakeTimers()/useRealTimers().

api/src/unraid-api/app/__test__/app.module.integration.spec.ts (1)

9-9: Import and init order LGTM.

Importing { loadDynamixConfig, store } with .js is correct, and loading config before loadStateFiles() is reasonable for these tests.

Also applies to: 113-113

packages/unraid-shared/src/index.ts (1)

7-11: Public re-exports look good.

ESM .js specifiers and export type usage are correct; no side effects introduced.

api/src/store/index.ts (2)

3-8: LGTM! Imports follow ESM guidelines.

All imports correctly use .js extensions for ESM compatibility.


51-51: LGTM! Getter appropriately leverages TTL-cached loading.

The shift from passive state access to active loading via loadDynamixConfig() is well-suited to the FAT32 use case described in the PR objectives. The TTL memoization (250ms) ensures that consecutive accesses within short intervals don't trigger redundant disk reads, making this pattern both correct and performant.

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +25 to +60
type ConfigPaths = readonly (string | undefined | null)[];
const CACHE_WINDOW_MS = 250;

const memoizedConfigLoader = createTtlMemoizedLoader<
RecursivePartial<DynamixConfig>,
ConfigPaths,
string
>({
ttlMs: CACHE_WINDOW_MS,
getCacheKey: (configPaths: ConfigPaths): string => JSON.stringify(configPaths),
load: (configPaths: ConfigPaths) => {
const validPaths = configPaths.filter((path): path is string => Boolean(path));
if (validPaths.length === 0) {
return {};
}
const configFiles = validPaths.map((path) => loadConfigFileSync<DynamixConfig>(path));
return configFiles.reduce<RecursivePartial<DynamixConfig>>(
(accumulator, configFile) => ({
...accumulator,
...configFile,
}),
{}
);
},
});

/**
* Load the dynamix.cfg into the store.
* Loads dynamix config from disk with TTL caching.
*
* Note: If the file doesn't exist this will fallback to default values.
* @param configPaths - Array of config file paths to load and merge
* @returns Merged config object from all valid paths
*/
export const loadDynamixConfigFile = createAsyncThunk<
RecursiveNullable<RecursivePartial<DynamixConfig>>,
string | undefined
>('config/load-dynamix-config-file', async (filePath) => {
if (filePath) {
return loadConfigFile<DynamixConfig>(filePath);
}
const store = await import('@app/store/index.js');
const paths = store.getters.paths()['dynamix-config'];
const { data: configs } = await batchProcess(paths, (path) => loadConfigFile<DynamixConfig>(path));
const [defaultConfig = {}, customConfig = {}] = configs;
return { ...defaultConfig, ...customConfig };
});
export const loadDynamixConfigFromDiskSync = (
configPaths: readonly (string | undefined | null)[]
): RecursivePartial<DynamixConfig> => {
return memoizedConfigLoader.get(configPaths);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Bust TTL cache after writes to dynamix.cfg

The new memoized loader keeps a 250 ms snapshot of the config (memoizedConfigLoader), but there is no way to invalidate that cache after the config file is modified. Callers such as CustomizationService.applyDisplaySettings read the current config through getters.dynamix() before writing updates and then immediately call getters.dynamix() again to return the updated theme. Because the cache is still valid, the second read reuses the pre‑update snapshot and the mutation responds with stale values until the TTL expires. Consider exposing a clear() or bypassing the cache when a write occurs so that reads performed in the same request reflect the new file contents.

Useful? React with 👍 / 👎.

🤖 I have created a release *beep* *boop*
---


##
[4.25.3](v4.25.2...v4.25.3)
(2025-10-22)


### Bug Fixes

* flaky watch on boot drive's dynamix config
([ec7aa06](ec7aa06))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a9ea211 and 0a902b0.

📒 Files selected for processing (3)
  • .release-please-manifest.json (1 hunks)
  • api/CHANGELOG.md (1 hunks)
  • package.json (1 hunks)
✅ Files skipped from review due to trivial changes (1)
  • .release-please-manifest.json
🔇 Additional comments (1)
package.json (1)

4-4: Version bump is correct.

The patch version increment (4.25.2 → 4.25.3) is appropriate for a bug fix.

Comment on lines +3 to +8
## [4.25.3](https://github.com/unraid/unraid-api/compare/v4.25.2...v4.25.3) (2025-10-22)


### Bug Fixes

* flaky watch on boot drive's dynamix config ([ec7aa06](https://github.com/unraid/unraid-api/commit/ec7aa06d4a5fb1f0e84420266b0b0d7ee09a3663))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix the compare URL repository path

The new 4.25.3 entry points to https://github.com/unraid/unraid-api/compare/..., but the rest of this changelog—and the actual repo—use https://github.com/unraid/api/compare/.... That typo breaks the compare link for this release. Please change the URL to https://github.com/unraid/api/compare/v4.25.2...v4.25.3 to keep the changelog consistent and the link working.

🤖 Prompt for AI Agents
In api/CHANGELOG.md around lines 3 to 8, the compare URL for version 4.25.3 uses
the wrong repository path "unraid/unraid-api"; update the URL to use
"unraid/api/compare/v4.25.2...v4.25.3" so the compare link matches the
repository and the rest of the changelog; ensure only the repo segment is
changed and keep the rest of the line intact.

@pujitm pujitm force-pushed the fix/config-watch branch 2 times, most recently from 275e1de to 0a902b0 Compare October 22, 2025 19:37
@github-actions
Copy link
Contributor

🚀 Storybook has been deployed to staging: https://unraid-ui-storybook-staging.unraid-workers.workers.dev

@github-actions
Copy link
Contributor

This plugin has been deployed to Cloudflare R2 and is available for testing.
Download it at this URL:

https://preview.dl.unraid.net/unraid-api/tag/PR1753/dynamix.unraid.net.plg

@pujitm pujitm merged commit 34075e4 into main Oct 27, 2025
13 of 14 checks passed
@pujitm pujitm deleted the fix/config-watch branch October 27, 2025 15:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant